Saving Files and Testing Games

This page describes part of the acron Python framework that comes with the acron wheel. It has no C++ counterpart.

Two modules of the acron framework deal with things outside the game loop: acron.persist keeps files between sessions – saves, settings, progress – and acron.testing drives the real engine from a test or a tool script.

Saving files

A game must start whatever is on disk, and a save must never be left half-written. acron.persist reads JSON defensively, writes it atomically, and brings old files forward to the current format. Where the files live and what they are called is up to the game.

from pathlib import Path
from acron import persist

SAVE = Path.home() / ".mygame" / "save.json"
VERSION = 3

def upgrade_1(d):
    d["coins"] = d.pop("gold", 0)
    return d

def upgrade_2(d):
    d.setdefault("unlocked", [])
    return d

def load():
    d = persist.read_object(SAVE) or {}
    return persist.migrate(d, VERSION, {1: upgrade_1, 2: upgrade_2})

def save(state):
    state["v"] = VERSION
    if not persist.write_json(SAVE, state):
        show_message("The game could not be saved.")

read_json(path, fallback=None)

the JSON in the file, or fallback when it is missing, cannot be read or does not parse. A file that does not parse is moved aside as <name>.corrupt (quarantine()), so it can be looked at later

read_object(path)

the same, but anything other than a JSON object reads as None

write_json(path, data)

writes through a temporary file, an fsync and a rename, so a crash leaves the previous file rather than an empty one; creates the directory. Returns whether the file was written

remove(path)

removes a file; False only when it exists and could not be removed

migrate(d, version, upgrades)

walks d forward one version at a time through upgrades, a dictionary of {from_version: function}, up to version

migrate() reads the file’s version from its v key (schema_version()); a file without one, or with one that is not a positive integer, is version 1. The walk stops at a version the table has no step for, and a file newer than version is returned as it is, for the game to take what it recognises.

Testing games

acronmanager test (the launcher’s Test button) runs the project’s tests with pytest, from the project’s environment; acronmanager test -- -k shadows -x passes arguments on to pytest, and [tool.acron.test] command in pyproject.toml replaces pytest with a command of the project’s own. acron.testing has the pieces for tests and tool scripts that run the game’s real engine rather than a mock.

acron.testing needs numpy, which the web runtime does not have; it is for tests and tools, not for a shipped game.

Running the app offscreen

from acron import testing
testing.offscreen("960x540")       # before ShowBase

from direct.showbase.ShowBase import ShowBase
base = ShowBase()
testing.fixed_clock(base)          # 1/60 s per frame
start_game(base)
testing.step(base, 120)            # two seconds of game time
base.win.save_screenshot("after-two-seconds.png")
  • ~acron.testing.offscreen(size=None) asks for an offscreen buffer instead of a window (size like "960x540"). It must run before ShowBase is created.

  • ~acron.testing.fixed_clock(base, dt=1/60) puts the clock in non-real-time mode with a fixed frame time. Offscreen, frames render as fast as they can, and a real-time clock would leave a fixed-step game loop with too few steps.

  • ~acron.testing.step(base, frames) runs that many frames, each of which renders.

  • ~acron.testing.after_call(obj, name, extra) wraps the method obj.name so that extra() runs after it, every call. It is how a test drives something that rewrites its own state every frame, such as an input poll: setting the fields directly does not stick, overriding them after the update does.

  • memory_footprint() is the process’s physical memory footprint in bytes, for a test that checks that a released scene really frees its memory.

Comparing frames

Some rendering bugs – flicker from state order, wrong winding, a uniform that is never set – leave every value a test can read correct and show only on screen. These functions compare rendered frames:

  • ~acron.testing.frame_rgb(win) returns the window’s last frame as a texture and an int16 RGB array;

  • ~acron.testing.pair_stats(a, b) returns the mean absolute RGB difference of two such arrays and the percentage of pixels that differ;

  • ~acron.testing.flicker_scan(base, frames=40) steps that many frames and returns the stats of each consecutive pair. Stop everything that animates on purpose first; on a still scene, a mean difference above 1 is visible flashing;

  • ~acron.testing.gl_spam_frame(base, path) renders one frame with the OpenGL renderer’s call stream (program and texture binds, changed uniforms) written to path, and returns that frame as frame_rgb() does, so the log of a frame that differs can be compared with one that does not.

stats = testing.flicker_scan(base, frames=40)
assert max(mean for mean, pct in stats) <= 1.0

Clicking in a real window

acron.testing.ClickDriver(base, *, settle_frames=2) moves the pointer and presses the mouse button through the window’s own input device, so the click goes through the same path as a player’s. It runs a few frames between each stage, so that RmlUi has seen the hover before the press.

  • press_at(x, y, hold=False) presses at a window pixel, and releases unless hold; release() releases.

  • click_element(element) clicks the centre of an RmlUi element. Element offsets are already in window pixels; do not scale them by the dp ratio.

acron.testing.Checklist prints PASS/FAIL lines and counts the failures, for a tool script that proves several things in a row:

checks = testing.Checklist()
clicks = testing.ClickDriver(base)
clicks.click_element(doc.get_element_by_id("btn_start"))
checks.check("the start button starts the game", game.running)
raise SystemExit(checks.exit_code)