Skip to content

Code conventions

These are the rules for code in Coney: the C++ engine under src/, the asset tools under tools/ and the Python package under python/. They exist so that anyone can open a file, understand what it does and why, and find the part of the original game it replaces. Reviewers check code against this page.

Coney is a reimplementation: engineers study the original and write Coney's own code (Studying the original). Several rules below, @orig tags and research links above all, are there to keep the path from a line of our code back to its research visible.

The examples below are abridged or simplified from the real code (src/fileio/wad_index.h, src/fileio/reader.h, src/core/error.h, src/core/assert.h). Where the code differs from an example, the code is right and this page is updated to match it.

Licence header

Every file of code we write starts with an SPDX line naming its licence, so the licence survives when a file is copied out of the repository on its own:

// SPDX-License-Identifier: GPL-3.0-or-later
# SPDX-License-Identifier: GPL-3.0-or-later

The scope, precisely:

Files Header
Coney's own code: C++ sources and headers, Python, CMake files, shell and PowerShell scripts (such as tools/start-ghidra-mcp.ps1) The SPDX line above, as the first line (after a #! line if the file has one). In a PowerShell script, leave a blank line after it: a comment directly above the <# ... #> help block hides that block from Get-Help
Code a generator writes into src/, tools/ or python/ The same line; the generator emits it
Documentation in docs/ None. The whole folder is CC-BY-SA-4.0 through docs/LICENSE; a header would only clutter the rendered page
Generated documents (docs/references/, docs/missions/) None. They carry a "Generated by ..., Do not edit" line instead and take the licence of the folder they live in
Files that cannot hold a comment (JSON such as CMakePresets.json) and small config files None. They are covered by LICENSE at the repository root

The licences themselves are set in LEGAL.md: code GPL-3.0-or-later, documentation CC-BY-SA-4.0.

C++

Standard and compilers

Coney is written in C++23, limited to what all four supported compilers implement:

Compiler Minimum Platform
MSVC 19.38 (Visual Studio 2022 17.8) Windows
GCC 13 Linux
Clang 17 (19 on Linux, see below) Linux
Apple Clang Xcode 16 macOS

In practice that gives us std::expected, std::span and std::format, which the rules below rely on. Clang 17 and 18 meet the language rules but not on Linux with libstdc++, which declares std::expected only for a compiler that reports C++20 concepts support in full, which Clang first does in version 19 (What you need). We do not use C++ modules: compiler and CMake support for them is still uneven across these four, and one platform failing to build is worse than the benefit.

Every build is warning-free, and CI treats warnings as errors (/W4 /WX on MSVC; -Wall -Wextra -Wpedantic -Werror elsewhere). A warning on one compiler is often a real portability bug on another.

Portability traps

Most local builds are MSVC, and CI is the first to try GCC and Clang. These pass on MSVC and fail elsewhere, so avoid them from the start:

  • Partial designated initializers that skip a member in between or initialise out of declaration order.
  • Parameters taken by value where clang-tidy wants const& (anything bigger than a couple of words).
  • Unchecked access to an std::optional (*opt or opt-> without a check in the same scope).
  • Fused multiply-add: GCC and Apple clang (arm64) fuse a * b + c unless told not to, MSVC and x86 builds do not, so identical float code differs in its low bits. Coney targets build with -ffp-contract=off (cmake/coney_target.cmake); keep it, since replays and tests need the same floats on every platform.
  • const locals returned by value: the const blocks the move on return.

Build

CMake presets with Ninja; dependencies are pinned in cmake/deps.cmake. Prerequisites, the presets and what CI runs are in Building and testing.

Formatting

clang-format does the formatting; nobody formats by hand. The configuration (.clang-format at the repository root) is:

  • the LLVM style as the base;
  • 4-space indentation;
  • lines of at most 120 columns.

pre-commit runs clang-format on every commit, so formatting never appears in review. Don't fight it with manual alignment; if clang-format makes something unreadable, restructure the code instead.

File layout

  • One class per header and source pair. wad_index.h declares WadIndex, and wad_index.cpp defines it. A small helper type used only by that class may share its files.
  • Folders mirror the original source tree. The original game was built from files under c:/Warriors/Source/<Subsystem>/; their paths survive in the executable's debug strings. Our code goes in src/<subsystem>/, with the folder and file names lowercased to snake_case: c:/Warriors/Source/Human/Human.cpp becomes src/human/human.cpp. Anyone holding an address, a research page or an original file name can then find our code, and the other way round. The PS2 device layer has no folder of its own: what it did is either part of a subsystem (the WAD index from Device/ps2/fileio/ lives in src/fileio/) or platform code (src/platform/, see Platform code).
  • Folders appear with their first code. Never create an empty placeholder folder.
  • Tests mirror src/. The tests for src/fileio/wad_index.cpp go in tests/fileio/wad_index_test.cpp.
  • Headers use .h and #pragma once, which all four compilers support and which can't go stale the way include guards can.

Include order

Includes come in four groups, separated by a blank line, each sorted alphabetically:

  1. the file's own header (in a .cpp), so a header that doesn't include what it needs fails to compile at once;
  2. the C++ standard library (<cstdint>, <expected>, ...);
  3. third-party libraries (SDL3, librw, Catch2);
  4. other Coney headers, by path from src/ ("core/error.h", "fileio/reader.h").
// SPDX-License-Identifier: GPL-3.0-or-later
#include "fileio/wad_index.h"

#include <cstdint>
#include <span>

#include "core/error.h"
#include "fileio/reader.h"

Include what you use, and nothing else; never rely on a header arriving through another one.

Naming

What Style Example
Files and folders snake_case src/fileio/wad_index.cpp
Namespaces lower coney, coney::io
Types (classes, structs, enums, aliases) PascalCase WadIndex, WadDirEntry, ErrorCode
Enumerators (always in an enum class) PascalCase ErrorCode::Truncated
Functions camelCase parse, readU32Le
Variables and parameters camelCase entryCount, dirData
Data members m_camelCase m_entries
Constants (constexpr and const at namespace or class scope) kPascalCase kSectorSize
Macros CONEY_UPPER CONEY_ASSERT

All engine code is inside the coney namespace, with one nested namespace per subsystem (coney::io for disc and file access). Macros carry the CONEY_ prefix because they ignore namespaces and would otherwise collide with SDL3, librw or the platform headers.

Names describe what the thing is in our design. They may match the original's name when that is the clearest choice, but they don't have to: Coney has its own names and structure, and the @orig tag (below) records the original name, so nothing is lost. For example, the original DVDWadIndex becomes WadIndex, since nothing in Coney reads from a DVD drive directly.

Comments and @orig

Coney's comments are written for someone who needs to find where a behaviour lives in the original and port it, and for a reviewer reading a diff without the docs open. That sets these rules.

  1. Every public type and function has a doc comment (///), saying what it is for, what it returns and how it fails. A reader should be able to use it without opening the .cpp.
  2. Every other function has a comment above it too: private members, helpers in an anonymous namespace, static functions, and lambdas longer than a few lines. One line saying what it does and why it exists is usually enough (// Reads one chunk and runs its handlers: the loop body both loaders share.). Skip it only where the name says everything (a getter, a one-line forwarder). A definition in a .cpp whose declaration already carries the doc comment needs none; don't repeat it.
  3. A long function is signposted: a short comment before each step of a function that does several things, so a reviewer can follow it without holding the whole of it in their head.
  4. Tests say what they prove: the TEST_CASE name states the behaviour, and a fixture or a hand-built byte layout gets a comment saying what it stands for.
  5. Short by default. Most comments are one or two lines. Write longer only where the subject needs it: an invariant, a format quirk, a deliberate difference from the original. Never narrate the code line by line.
  6. Every non-obvious line explains why, not what. // Seek to 0x10 repeats the code; // The 12 bytes after the count are padding: the entries start at 0x10 tells the reader something the code can't.
  7. The doc comment links the research page that describes the behaviour, by its path in the repository (docs/research/formats/wad-dir.md), which works both in an editor and on GitHub.
  8. No commented-out code. Git keeps the history; dead code in comments only makes the reader wonder whether it matters.

The @orig tag

Everything that reimplements a function of the original game carries an @orig tag in its doc comment:

@orig 0x<address> <OriginalName> (<File>.cpp)
  • Address: the original function's entry point in the NTSC-U executable SLUS_212.15, written with eight hex digits (0x001490b8).
  • OriginalName: the name the function's research page gives it (the research database, which will hold the names in one place, is planned: the research database).
  • File: the original translation unit's file name, when it is known. When it isn't, write (unknown) and fix the tag when research finds it.

One tag per original function. A Coney function that does the work of two original functions carries two tags; a helper that does part of an original function's work (a step the original inlined) carries that function's tag. A function with no original counterpart carries @coney-only <reason> instead (Coney-only code).

Every tag is counted. In the same commit as the code, add a [[function]] block for each newly tagged original function to docs/progress/functions.toml (address, original name, subsystem and, when known, size), then run uv run --project python coney-tools progress check. Nothing generated is committed: the progress page's tables come from the docs build. CI fails when a tag and the file disagree. coney-tools progress sizes --fill finds a size from your own disc. Details: the progress commands and Progress.

A complete example

The header below shows the doc comments, the research link and the @orig tags together. It is written from the WARRIORS.DIR research page, which describes the file layout and the lookup; it is simplified from the real src/fileio/wad_index.h (the real one also reports an entry past the end of WARRIORS.WAD and has more members).

// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once

#include <cstddef>
#include <cstdint>
#include <expected>
#include <span>
#include <string_view>
#include <vector>

#include "core/error.h"

namespace coney::io {

/// One entry of WARRIORS.DIR: where one file lives inside WARRIORS.WAD.
/// Layout: docs/research/formats/wad-dir.md
struct WadDirEntry {
    std::uint32_t wadOffset = 0; ///< Byte offset into WARRIORS.WAD; a multiple of 2048.
    std::uint32_t size = 0;      ///< Size of the file in bytes.
    std::uint32_t nameHash = 0;  ///< CRC-32 of the lowercased path (docs/research/name-hash.md).
};

/// The table of every file in WARRIORS.WAD, read from WARRIORS.DIR.
///
/// Research: docs/research/formats/wad-dir.md
class WadIndex {
public:
    /// Parses a complete WARRIORS.DIR image.
    /// Fails with ErrorCode::Truncated when the data is shorter than its entry count says.
    /// The original does this work in its constructor; a factory function can report failure, a constructor can't.
    /// @orig 0x00149160 DVDWadIndex::DVDWadIndex (DVDWadIndexPS2.cpp)
    static std::expected<WadIndex, Error> parse(std::span<const std::byte> dirData);

    /// Finds the entry for a path such as "./ee_files/global.lua", in any letter case.
    /// Returns nullptr when no entry matches. The pointer stays valid as long as this index.
    /// @orig 0x001490b8 DVDWadIndex::Find (DVDWadIndexPS2.cpp)
    const WadDirEntry* find(std::string_view path) const;

private:
    std::vector<WadDirEntry> m_entries;
};

} // namespace coney::io

Porting, not transcribing

A reimplementation keeps the original's behaviour and logic and is written in Coney's own expression. This is a source-level rule: the compiled code can never match, because the original is MIPS code for the EE built by an old GCC, and Coney is x86-64 or ARM code built by MSVC, GCC or Clang.

What must match the original:

  • what the function does: the same inputs give the same effects, in the same order;
  • the math and constants, branch conditions, loop bounds, timings, random draws, state writes and the calls made.

A difference is allowed only on purpose, with a comment at the difference that says why (a platform substitution, an id in place of a pointer, network play).

What must be ours:

  • Coney's types, names, ids and APIs, never offsets into a raw block of memory;
  • a structure that Coney's design decides (its classes, events and tables), not the listing's statement order;
  • names that say what a value means, and comments that give the reason.

A function written from the decompiler's output is a transcription, even after its variables are renamed. Its signs are:

  • the listing's statement-by-statement shape;
  • Ghidra's temporaries (iVar1, param_1, local_10), its undefined4 types and its casts;
  • raw *(T *)(x + 0x..) reads;
  • goto;
  • dead code and compiler artifacts carried over.

Here is an invented example, not game code. Bad: the listing's shape, Ghidra's names and offsets.

// @orig 0x00123450 Lantern_Tick (Lantern.cpp)
void Lantern::tick(int param_1) {
    int iVar1 = *(int *)(param_1 + 0x40);
    if (iVar1 < 1) goto LAB_done;
    *(int *)(param_1 + 0x40) = iVar1 - 1;
    if (iVar1 - 1 == 0) {
        *(float *)(param_1 + 0x44) = 0.0f;
    }
LAB_done:
    return;
}

Good: the same logic in Coney's own types, with the reason in a comment.

/// Burns one tick of oil; the light goes out on the tick the oil runs dry, not the one after.
/// @orig 0x00123450 Lantern_Tick (Lantern.cpp)
void Lantern::tick() {
    if (m_oilTicks <= 0) {
        return;
    }
    --m_oilTicks;
    if (m_oilTicks == 0) {
        m_brightness = 0.0f;
    }
}

Two checks hold the rule:

  • coney-tools orig copy-check refuses the mechanical signs in changed lines (check changed and CI run it, orig). A deliberate case carries // copy-check: allow <reason>.
  • The orig-reviewer agent compares each changed @orig function with the original on both counts, fidelity and own expression, before release (Orig review).

Coney-only code

Every function in a gameplay folder (paths.gameplay in .github/conventions.yml: src/ai/, src/human/, ...) says where it comes from: @orig for a reimplementation of the original function at that address in Ghidra, or @coney-only <reason> for code with no counterpart in the original (a test aid, a debug tool, platform glue, an enhancement), in its doc comment:

/// Counts how often the target changed, for the all-humans trace (ai::FightTracer).
/// @coney-only trace bookkeeping; the original keeps no such count
std::uint32_t targetChanges() const;

The reason is a few words. A declaration's tag or marker covers its out-of-class definition, and a file that is wholly Coney's own says so once, in its header comment (// @coney-only the debug menus' Time page; the original has none). A file's private helpers (an anonymous namespace, static) are not exempt: one that does part of an original function's work carries that function's @orig, any other @coney-only. A tag on a class covers none of its members: each carries its own. Exempt are only a member defined in its class whose body is one statement with no branch or lambda (an accessor, a setter, a forwarder) and a constructor or destructor with an empty body. The folders of paths.coney_only (src/platform/, src/core/, src/fileio/, src/debug/, src/net/, src/sandbox/) are Coney's own by nature and need no marker; every folder of src/ is in one of the two lists. coney-tools orig check (orig), run by check changed and CI, refuses a new function that says neither, and a new folder in neither list. The functions written before the rule are the fidelity audit's backlog (orig index); the older wording ("Coney's own", "(no @orig)") is still read as a marker on them until they are converted, never on a new function.

Coney-only capabilities

Some code has no counterpart in the original: a debug tool, a test aid, an enhancement. When such a capability is something a script could plausibly want (flying a human, teleporting, spawning, pausing, reading or writing a tunable), it is a future native of Coney's own (Script mods), so two rules apply:

  • Register it in the same commit. Add a row to Coney native candidates: a proposed Coney.Name, the C++ entry point, what it does and its status (engine-only until a script can call it). When it is exposed, change the status in the same commit as the registration. A capability that no script could use (a front end, a test aid such as --frames) is not listed.
  • Keep its C++ API apart from reimplemented code. The entry point is its own function, with no @orig tag and a @coney-only marker that says the original has nothing like it (Human::setFlight() is the model). Never hang it on an @orig function, add a flag to one, or let one call it: the original's behaviour must read the same with every Coney capability off. Its natives live in the Coney table and never appear in research/bindings/, which describes only the original's 956.

Errors

The engine does not throw exceptions. Exceptions make every call a possible exit that the code doesn't show, which is hard to follow for someone reading a function for the first time, and they are costly to reason about at the boundary with C libraries such as SDL3 and librw. Instead:

  • Recoverable errors (a missing file, truncated data, a device that went away) return std::expected<T, coney::Error>. The caller sees in the signature that the call can fail and must decide what to do.
  • Programmer errors (a broken invariant, an index out of range, a call in the wrong state) use CONEY_ASSERT. These are bugs, not situations to recover from, so the program stops and reports where.

The API, from src/core/error.h and src/core/assert.h (abridged: the enum has two more codes, and CONEY_ASSERT is a macro with a failure function behind it):

namespace coney {

/// What went wrong, in a form code can branch on.
enum class ErrorCode {
    NotFound,  ///< A file or entry does not exist.
    Truncated, ///< Data ended before its own header said it would.
    Invalid,   ///< Data is present but breaks its format's rules.
    Io,        ///< The operating system reported a read or write failure.
};

/// A recoverable error: a code to branch on and a message for the log.
struct Error {
    ErrorCode code;
    std::string message; ///< For people reading logs; never parsed by code.
};

} // namespace coney

/// Stops the program with the condition, file and line when `condition` is false.
/// Only for programmer errors; never for bad input data.
#define CONEY_ASSERT(condition) /* defined in src/core/assert.h */

A short example, continuing the WadIndex above:

std::expected<WadIndex, Error> WadIndex::parse(std::span<const std::byte> dirData) {
    Reader reader(dirData);
    auto entryCount = reader.readU32Le();
    if (!entryCount) {
        return std::unexpected(entryCount.error());
    }
    // The 12 bytes after the count are padding: the entries start at 0x10.
    if (auto seeked = reader.seek(0x10); !seeked) {
        return std::unexpected(seeked.error());
    }
    // Check the size before allocating, so a corrupt count can't make us reserve gigabytes.
    constexpr std::size_t kEntrySize = 12;
    if (reader.remaining() / kEntrySize < *entryCount) {
        return std::unexpected(Error{ErrorCode::Truncated, "WARRIORS.DIR is shorter than its entry count"});
    }

    WadIndex index;
    index.m_entries.reserve(*entryCount);
    for (std::uint32_t i = 0; i < *entryCount; ++i) {
        // The size check above covers every read in this loop, so these cannot fail.
        WadDirEntry entry;
        entry.wadOffset = reader.readU32Le().value();
        entry.size = reader.readU32Le().value();
        entry.nameHash = reader.readU32Le().value();
        index.m_entries.push_back(entry);
    }
    CONEY_ASSERT(index.m_entries.size() == *entryCount);
    return index;
}

Bad data from a disc is never a programmer error: a scratched disc or a modded file must produce an Error, not an assert. A parser that silently accepts truncated data is a Critical review finding.

Memory and data

  • RAII for every resource. Memory, files, SDL and librw handles are owned by an object whose destructor releases them, so no early return can leak them.
  • No owning raw pointers. Ownership is std::unique_ptr, a container, or a small RAII wrapper. A raw pointer or reference means "I'm looking at something someone else owns" and nothing more, as WadIndex::find above shows. What that means for a destructor is the next rule.
  • A destructor reads only what its object owns. It samples, logs or snapshots nothing through its references: their owners may be gone already, as a mode's members are destroyed in reverse order. It may detach itself from an object it is attached to only when that object is documented to outlive it (Player::setCameras says the cameras do); otherwise it touches nothing. For example, a player's destructor must not sample his pose, because a clip he plays can belong to a character freed before him.
  • Disc data is read through coney::io::Reader, with explicit little-endian reads (readU8, readU16Le, readU32Le, readF32Le, seek, remaining). Each read checks the bounds and returns std::expected<T, Error>, so running off the end of a buffer is an error rather than a crash.
  • Never reinterpret_cast a buffer to a struct or an integer, even when the struct matches the file layout byte for byte. Three things go wrong:

    • alignment: a std::uint32_t read from an arbitrary offset of a byte buffer may be misaligned, which is undefined behaviour and faults on some processors;
    • endianness: the PS2 data is little-endian; reading it field by field makes the byte order explicit in the code, and the code stays correct on any host;
    • strict aliasing: accessing bytes through a pointer to an unrelated type is undefined behaviour, and optimising compilers do exploit it. Struct padding can also differ between compilers.

    Reader assembles each value from bytes, which avoids all three, and the cost is negligible next to disc I/O.

The Reader interface, from src/fileio/reader.h (abridged; it also has readBytes):

namespace coney::io {

/// Reads little-endian values from a byte buffer it does not own, with bounds checks.
class Reader {
public:
    explicit Reader(std::span<const std::byte> data);

    std::expected<std::uint8_t, Error> readU8();
    std::expected<std::uint16_t, Error> readU16Le();
    std::expected<std::uint32_t, Error> readU32Le();
    std::expected<float, Error> readF32Le();

    /// Moves to an absolute offset. Fails with ErrorCode::Truncated past the end.
    std::expected<void, Error> seek(std::size_t offset);

    std::size_t position() const; ///< The offset of the next read.
    std::size_t remaining() const; ///< Bytes left after the current position.
};

} // namespace coney::io

Platform code

Code that talks to the operating system or a graphics API lives only in src/platform/: windowing, input, timing, threads, file-system paths, and the SDL3 and librw glue. Everything else in src/ is portable C++ that calls src/platform/ through Coney's own interfaces.

This keeps three platforms working with one engine: a port problem has one place to look, and the rest of the engine builds and tests the same way everywhere. An #ifdef _WIN32 outside src/platform/ is a review finding.

The same boundary protects the engine's test mode, a recorded requirement: Coney must be able to run with a fixed timestep, seeded randomness, scripted input and a local control channel, so that tests and reference comparisons against the original game are repeatable. Engine code therefore never reads the clock, the random source or the input devices directly; it asks the platform layer, which test mode can replace. Don't design this out.

Update and render

The game runs at the original's fixed step of exactly 1/30 s at any display rate, from 30 frames a second to uncapped, and the simulation comes out bit for bit the same at every rate. That holds only while every piece of code keeps to these rules:

  • The simulation reads only FrameTime. Gameplay, physics, animation, timers, scripts, streaming and input all happen in a game mode's update(), which is always exactly one fixed step and is handed the step's length and the game time. Nothing in the simulation measures time itself or assumes how many steps a real frame runs (0 to 4, up to 16 with the debug menus' time scale). Every gameplay constant in the research assumes this step.
  • Say whether a duration counts steps or time. The original times some things in steps (the 5 updates after a block, a think every fifth step) and others in milliseconds of game time (an attack delay, a block's length). Name a step count ...Steps (kBlockPauseSteps = 5) and a time ...Ms or ...Seconds, and never convert one into the other to "simplify". Times carry over to any step length; step counts are the list a different tick rate (a 60 Hz mode, a server, Roadmap) would have to rescale.
  • render() only draws. It runs once per real frame, after the frame's steps, with RenderTime::alpha: where between the last two steps the frame falls. It may read the simulation's state and blend the last two steps; it never changes anything update() reads. A mode never draws or presents in update().
  • Blend what moves continuously. Keep anything that moves smoothly (a camera, a character's position and heading, a playhead, a fade) as an Interpolated<T> (src/core/interpolation.h): commit() at the start of each update, change current(), and in render() blend previous() and current() with lerp, lerpAngle (the short way round) or lerpLooping (a looping playhead); a rotation is blended as a rotation, never as a matrix. Use reset() for a jump that must not be blended (a teleport, a new clip). A set of things that come and go (world objects, doors, icons) keeps a world_objects::DrawnPoses instead: commit(), then record() each one's pose in the update, and drawn() in render(); what is new or jumped is drawn unblended (Drawing between steps). Discrete state (a menu's selection, text, the GUI's sprite list for the step) is drawn as the newest step has it. Every helper returns the newest value exactly at alpha 1, so lockstep draws exactly what the simulation holds.
  • The simulation is the server's. What update() changes is what a network server would own and replicate; what render() and the platform layer keep is each machine's own. Network architecture says who owns what, and its checklist what every subsystem keeps to.
  • Pads are read once per step, by the stack, before the update. The platform's input source keeps every press until the next step reads it, so a tap shorter than a step is seen, and seen once.
  • Only the frame pacer reads the real clock (src/platform/frame_pacer.cpp). It measures each frame and holds frames to --fps-cap; core's FrameClock turns the time into steps. A pre-commit check refuses the clock and sleep calls (SDL_GetTicks, SDL_Delay, std::chrono's clocks and the like) in any other file under src/ and tests/.
  • Test mode is lockstep. --headless, --frames, --input-script and --screenshot run one step and one render at alpha 1 per frame, with no clock, so a run gives the same steps, state and pixels every time. --frames N counts steps. Its window never takes the keyboard focus (activatesWindow() in src/core/options.h): a run nobody plays must not interrupt someone typing.

The rules' tests: tests/core/frame_clock_test.cpp (30 steps a simulated second at any rate, no drift over an hour, the catch-up cap) and tests/gamemodes/frame_rate_test.cpp (the same simulation, bit for bit, at 30, 60, 144, 240 and 1000 frames a second and with irregular frames), and with the disc, tests/platform/disc_play_frame_rate_test.cpp (Rembrandt, the follow camera and the streaming in --play-level, the same way). Why the loop is built this way, and how it differs from the original's: Graphics.

Tests

Everything that can be tested without the game is written test-first, with Catch2 under tests/ (mirroring src/). What each kind of work must prove, the rules every test follows (synthetic fixtures, the player's path, gamepad input, playthroughs) and where the checks run are in Testing.

Python

python/coney_tools holds Coney-specific automation: the WAD extractor and name recovery, the PCSX2 bridge, and the harness that drives a running Coney or PCSX2 with scripted input.

  • Python 3.12 or newer, managed with uv: uv sync sets up the environment and uv run runs inside it, so everyone uses the same locked dependencies.
  • One command-line entry point: coney-tools <group> <command>, for example coney-tools wad extract. No loose scripts: a script outside the CLI has no tests, no --help and nowhere obvious to be found. The groups include wad, xbox, pcsx2 (the PCSX2 bridge) and trace (The coney-tools command line).
  • ruff formats and lints, mypy --strict type-checks, and pytest tests. All three run in CI; ruff also runs in pre-commit. Ruff covers python/ and the Claude Code hook scripts in .claude/hooks/, which take the same rules through their own ruff.toml; RUFF_PATHS in python/src/coney_tools/check_cli.py is the one list, and a test fails when CI's commands or pre-commit's files pattern name other folders. Strict typing matters here because these tools parse binary formats, where a wrong type is a silent wrong answer. The mypy command always passes --config-file python/pyproject.toml: it runs from the repository root, where mypy finds no config and would silently skip strict.
  • Repository rules are read, not copied. A value that is a rule of running the repository (a commit area, a title limit, a description section, a label, a path class, a limit in days) is a key of .github/conventions.yml, read through coney_tools/conventions.py; code never repeats it as a constant (Repository conventions).
  • pathlib only for paths, never string concatenation or os.path. Every tool runs on Windows, Linux and macOS, and pathlib handles the separators and drive letters.
  • Docstrings on every public module, class and function: a one-line summary, then whatever a caller needs (arguments, return value, errors raised), and a link to the research page when the code implements a format.

The same licence header, @orig and why rules apply as for C++. An example:

# SPDX-License-Identifier: GPL-3.0-or-later
"""Name hashing for WARRIORS.DIR lookups."""

import zlib


def name_hash(path: str) -> int:
    """Return the hash WARRIORS.DIR stores for `path`.

    The game lowercases a name before hashing it, so the hash ignores letter case.
    Research: docs/research/name-hash.md
    """
    return zlib.crc32(path.lower().encode("ascii"))

File size

A source file passing about 500 lines is a prompt to split it, usually along the lines of the original's own classes. Not a hard limit, but a reviewer will ask why. Large files are where a reader loses the thread, and they make reviews and merges harder.