diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..9ddd432 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,46 @@ +name: Build + +on: + push: + branches: [ main ] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + examples: + runs-on: ubuntu-latest + permissions: + contents: read + timeout-minutes: 30 + container: ghcr.io/grumpycoders/pcsx-redux-build:latest + + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + set-safe-directory: true + persist-credentials: false + + - name: OpenBIOS + run: make -C third_party/nugget/openbios -j2 all + + - name: Examples + run: | + for d in examples/*/; do + echo "::group::$d" + make -C "$d" -j2 all BOOK_CI=true + echo "::endgroup::" + done + + - name: Emulator + # The dev channel AppImage, extracted because a runner has no FUSE. + run: | + curl -sL -o pcsx-redux.zip https://distrib.app/pub/org/pcsx-redux/project/dev-linux-x64/latest + unzip -q pcsx-redux.zip + ./PCSX-Redux-*.AppImage --appimage-extract > /dev/null + + - name: Boot every example + run: ci/run-examples.sh "$GITHUB_WORKSPACE/squashfs-root/AppRun" third_party/nugget/openbios/openbios.bin diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c59337d --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*.o +*.dep +*.elf +*.map +*.ps-exe diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..d96a991 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "third_party/nugget"] + path = third_party/nugget + url = https://github.com/pcsx-redux/nugget.git diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2ad449e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 the learn authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ci/ci.hh b/ci/ci.hh new file mode 100644 index 0000000..064dce5 --- /dev/null +++ b/ci/ci.hh @@ -0,0 +1,22 @@ +// Hooks for the CI run that boots every example in PCSX-Redux. They compile +// to nothing unless the example is built with BOOK_CI=true, so the examples +// read the same as they would without them. + +#pragma once + +#ifdef BOOK_CI +#include "common/hardware/pcsxhw.h" +#include "common/syscalls/syscalls.h" + +// Print the line the CI script greps for, then leave the emulator with +// exit code 0. On hardware pcsx_exit is an ignored store. +#define BOOK_CI_PASS(fmt, ...) \ + do { \ + ramsyscall_printf("BOOK-PASS " fmt "\n", ##__VA_ARGS__); \ + pcsx_exit(0); \ + } while (0) +#else +#define BOOK_CI_PASS(fmt, ...) \ + do { \ + } while (0) +#endif diff --git a/ci/run-examples.sh b/ci/run-examples.sh new file mode 100755 index 0000000..3ea229d --- /dev/null +++ b/ci/run-examples.sh @@ -0,0 +1,63 @@ +#!/bin/bash +# Boot every example in PCSX-Redux and grade each on its BOOK-PASS line, +# never on the absence of a failure. +# +# ci/run-examples.sh +# +# Run from the repository root after building the examples with +# BOOK_CI=true. Every examples/*/ directory has to be listed below, so a new +# example cannot be left out of the run by accident. + +set -u + +if [ -z "${DISPLAY:-}" ]; then + exec xvfb-run -a "$0" "$@" +fi + +EMU=${1:?usage: $0 } +BIOS=${2:?usage: $0 } +TIMEOUT=${TIMEOUT:-60} + +# name;success regex +EXAMPLES=( + "frames;^BOOK-PASS frames count=[0-9]+ rate=(50|60)$" +) + +fail=0 +declare -A known +for e in "${EXAMPLES[@]}"; do known[${e%%;*}]=1; done +for d in examples/*/; do + d=$(basename "$d") + if [ -z "${known[$d]:-}" ]; then + echo "UNLISTED: examples/$d is not in $0" + fail=1 + fi +done + +LOGS=$(mktemp -d) +for e in "${EXAMPLES[@]}"; do + IFS=';' read -r name token <<< "$e" + exe="examples/$name/$name.ps-exe" + if [ ! -f "$exe" ]; then + echo "MISSING: $exe was not built" + fail=1 + continue + fi + log="$LOGS/$name.log" + home="$LOGS/home-$name" + mkdir -p "$home" + HOME="$home" XDG_CONFIG_HOME="$home/.config" timeout -k 10 "$TIMEOUT" "$EMU" -no-ui -run -stdout -testmode \ + -interpreter -bios "$BIOS" -loadexe "$exe" > "$log" 2>&1 + rc=$? + line=$(sed 's/\x1b\[[0-9;]*m//g' "$log" | tr -d '\r' | grep -aE "$token" | tail -1) + if [ $rc -eq 0 ] && [ -n "$line" ]; then + echo "PASS $name: $line" + else + echo "FAIL $name: exit $rc, no success line" + sed 's/\x1b\[[0-9;]*m//g' "$log" | tail -40 | sed 's/^/ /' + fail=1 + fi +done + +[ $fail -eq 0 ] && echo "all ${#EXAMPLES[@]} example(s) passed" +exit $fail diff --git a/examples/book.mk b/examples/book.mk new file mode 100644 index 0000000..f21aba4 --- /dev/null +++ b/examples/book.mk @@ -0,0 +1,8 @@ +# Shared by every example: the psyqo build from the nugget submodule, plus +# the CI hook switch. +BOOKDIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))).. +CPPFLAGS += -I$(BOOKDIR)/ci +ifeq ($(BOOK_CI),true) +CPPFLAGS += -DBOOK_CI -Werror +endif +include $(BOOKDIR)/third_party/nugget/psyqo/psyqo.mk diff --git a/examples/frames/Makefile b/examples/frames/Makefile new file mode 100644 index 0000000..d4e69e9 --- /dev/null +++ b/examples/frames/Makefile @@ -0,0 +1,7 @@ +TARGET = frames +TYPE = ps-exe + +SRCS = \ +frames.cpp \ + +include ../book.mk diff --git a/examples/frames/frames.cpp b/examples/frames/frames.cpp new file mode 100644 index 0000000..ccf0b26 --- /dev/null +++ b/examples/frames/frames.cpp @@ -0,0 +1,57 @@ +// Counts frames and shows the count, along with the refresh rate the GPU +// was configured for. Chapter 3, "Boot and the frame". + +#include "ci.hh" +#include "psyqo/application.hh" +#include "psyqo/font.hh" +#include "psyqo/gpu.hh" +#include "psyqo/scene.hh" + +namespace { + +class Frames final : public psyqo::Application { + void prepare() override; + void createScene() override; + + public: + psyqo::Font<> m_font; +}; + +class FramesScene final : public psyqo::Scene { + void frame() override; +}; + +Frames frames; +FramesScene framesScene; + +} // namespace + +void Frames::prepare() { + psyqo::GPU::Configuration config; + config.set(psyqo::GPU::Resolution::W320) + .set(psyqo::GPU::VideoMode::AUTO) + .set(psyqo::GPU::ColorMode::C15BITS) + .set(psyqo::GPU::Interlace::PROGRESSIVE); + gpu().initialize(config); +} + +void Frames::createScene() { + m_font.uploadSystemFont(gpu()); + pushScene(&framesScene); +} + +void FramesScene::frame() { + auto& gpu = frames.gpu(); + uint32_t count = gpu.getFrameCount(); + unsigned rate = gpu.getRefreshRate(); + + gpu.clear({{.r = 0, .g = 0, .b = 0x30}}); + frames.m_font.printf(gpu, {{.x = 16, .y = 32}}, {{.r = 0xff, .g = 0xff, .b = 0xff}}, "frame %u", count); + frames.m_font.printf(gpu, {{.x = 16, .y = 48}}, {{.r = 0xff, .g = 0xff, .b = 0xff}}, "%u Hz, %u.%02u s", rate, + count / rate, (count % rate) * 100 / rate); + + // Two seconds of frames is enough to show the vblank is ticking. + if (count >= 2 * rate) BOOK_CI_PASS("frames count=%u rate=%u", count, rate); +} + +int main() { return frames.run(); } diff --git a/third_party/nugget b/third_party/nugget new file mode 160000 index 0000000..4828df5 --- /dev/null +++ b/third_party/nugget @@ -0,0 +1 @@ +Subproject commit 4828df55035b3e6f6ae1f331eefc01fbd80f7adf