diff --git a/.github/BRANCHING-STRATEGY.md b/.github/BRANCHING-STRATEGY.md new file mode 100644 index 000000000000..799c6d2cba4c --- /dev/null +++ b/.github/BRANCHING-STRATEGY.md @@ -0,0 +1,213 @@ +# Branching Strategy for your RetroArch fork + +## Core Concepts + +Before reading further, understand these three things: + +### What is a rebase? +A rebase takes your commits and **replays them on top of a different base commit**, +keeping history linear. Instead of a merge commit, your work simply moves forward: +``` +Before: [RA v1.22.2] ── [your feature A] ── [your feature B] +After: [RA v1.23.0] ── [your feature A'] ── [your feature B'] +``` +Your features travel forward automatically. Requires `git push --force-with-lease` +because history is rewritten. + +### What is a merge? +A merge joins two branches with a **merge commit**, preserving both histories. +Simpler than rebase, no force-push needed, but history accumulates merge commits +over time and becomes harder to read. + +### Why rebase for this fork? +Because you are the sole developer, the force-push risk is zero. Rebase keeps +`main` perfectly readable: upstream commits first, then your commits on top — +making it immediately obvious what you've added versus upstream. + +--- + +## The Four Concerns + +| Concern | Lives where | +|---|---| +| Upstream RA source snapshots (read-only) | `retroarch-releases/` | +| Early breakage detection | `octelys/upstream-sync` (robot-owned, never touch directly) | +| Your custom features | `feature/` branches → PR → `main` | +| Your fork releases | `main` → tagged | + +--- + +## Branch Map + +``` +libretro/RetroArch + │ + ├── master ──────────────────────────────────────► octelys/upstream-sync + │ daily, via upstream-sync.yml Robot-owned canary branch. + │ Contains: upstream master + │ + your feature commits on top. + │ CI runs here. + │ NEVER develop or commit here directly. + │ NEVER merge this PR into main. + │ Force-overwritten every day. + │ + ├── tag v1.22.2 ──► retroarch-releases/v1.22.2 Read-only upstream snapshot. + │ via release-branch-sync.yml Used ONLY as a rebase target + │ for main. Never commit here. + │ + └── tag v1.23.0 ──► retroarch-releases/v1.23.0 (same) + │ + │ YOU manually rebase main onto this + │ when ready to absorb the new release + ▼ + main: [v1.23.0 code] ── [feature A'] ── [feature B'] + │ │ + │ you branch off here git tag octelys.1 + │ │ + ├── feature/ws-server ──► PR ──► main ▼ + ├── feature/achievement-events ► PR ──► main GitHub Release + └── feature/... "RetroArch 1.23.0-octelys.1" +``` + +--- + +## The two automated pipelines + +### Pipeline 1 — `upstream-sync.yml` (daily canary) + +**Trigger:** every day at 06:00 UTC, or manually. + +**What it does:** +1. Fetches the latest upstream `master` +2. Creates branch `octelys/upstream-sync` = upstream master + your feature commits from `main` cherry-picked on top +3. Force-pushes it (overwrites yesterday's canary) +4. Opens (or updates) a PR so CI runs against it + +**What you do with it:** +- ✅ CI green → your features are compatible with today's upstream. Close the PR. +- ❌ CI red → upstream broke something. Fix your `feature/*` branch, merge it into `main`. The next daily run will reopen a fresh canary. +- 🚫 Never merge this PR into `main`. +- 🚫 Never commit to `octelys/upstream-sync` directly. + +--- + +### Pipeline 2 — `release-branch-sync.yml` (release tracker) + +**Trigger:** every day at 07:00 UTC, or manually (you can supply a specific tag). + +**What it does:** +1. Fetches the latest upstream release tag (e.g. `v1.23.0`) +2. Checks whether `retroarch-releases/v1.23.0` already exists in your fork +3. If not, creates and pushes it at the exact release commit SHA + +**What you do with it:** +- When you see a new `retroarch-releases/` branch appear, that is your signal + that a new upstream release has shipped and you can absorb it into `main`. +- 🚫 Never commit to these branches. + +--- + +## Step-by-step: absorbing a new upstream release into main + +> Do this after a new `retroarch-releases/` branch has appeared AND the +> daily canary CI is green (your features are confirmed compatible). + +```zsh +# 1. Pull the new release branch from your fork +git fetch origin retroarch-releases/v1.23.0 + +# 2. Switch to main +git checkout main + +# 3. Rebase your feature commits on top of the new upstream release +git rebase origin/retroarch-releases/v1.23.0 + +# 4. If conflicts appear during rebase: +# For .github/workflows — always keep YOUR version: +# git checkout --ours .github/workflows/ +# git add .github/workflows/ +# git rebase --continue +# For source files — resolve manually, then: +# git add +# git rebase --continue + +# 5. Push (force required because rebase rewrites commit SHAs) +git push origin main --force-with-lease +``` + +After this, `main` = `[RA v1.23.0 code] + [your features]`. ✅ + +--- + +## Step-by-step: publishing a fork release + +> Do this after main has been rebased onto the desired upstream release. + +```zsh +# Your tag is just your own suffix. +# Build.yml automatically fetches the upstream version and prepends it. +git tag octelys.1 +git push origin octelys.1 +# → Build.yml fires → GitHub Release created: "RetroArch 1.23.0-octelys.1" +``` + +To publish a second release on the same upstream version: +```zsh +git tag octelys.2 +git push origin octelys.2 +# → GitHub Release: "RetroArch 1.23.0-octelys.2" +``` + +--- + +## Step-by-step: developing a new feature + +```zsh +# 1. Branch off main (which is already on top of the latest upstream release) +git checkout -b feature/my-new-thing main + +# 2. Work, commit as usual +git add . +git commit -m "feat: my new thing" + +# 3. Push and open a PR into main +git push origin feature/my-new-thing +# → Open PR on GitHub: feature/my-new-thing → main +``` + +--- + +## What each branch is — quick reference + +| Branch | Owner | Purpose | Can I commit here? | +|---|---|---|---| +| `main` | You | Latest stable RA release + your features. Release base. | ✅ Yes (via PRs) | +| `feature/` | You | Work-in-progress feature | ✅ Yes | +| `retroarch-releases/` | Robot | Upstream release snapshot. Rebase target only. | 🚫 No | +| `octelys/upstream-sync` | Robot | Daily canary. CI alarm bell. | 🚫 No | + +--- + +## What each pipeline does — quick reference + +| Workflow | Trigger | Role | Touches main? | +|---|---|---|---| +| `upstream-sync.yml` | Daily 06:00 UTC | Canary: upstream master + your features → CI | 🚫 No | +| `release-branch-sync.yml` | Daily 07:00 UTC | Creates `retroarch-releases/` on new upstream release | 🚫 No | +| `Build.yml` | Your tag push | Builds and publishes fork release. Detects RA version from git history, names release `-`. | 🚫 No | +| `MacOS.yml` / `Windows-x64-MXE.yml` / etc. | Called by `Build.yml` or PRs | CI builds | 🚫 No | + +--- + +## Summary + +> `retroarch-releases/` = frozen upstream snapshots — rebase targets, nothing else +> +> `octelys/upstream-sync` = daily robot canary — read CI result, never commit +> +> `main` = **your branch** — latest stable RA release + your features — develop and release from here +> +> `feature/` = short-lived work branches — PR into `main` when done +> +> tags on `main` = your published releases + diff --git a/.github/workflows/Build.yml b/.github/workflows/Build.yml new file mode 100644 index 000000000000..7453dfc0e150 --- /dev/null +++ b/.github/workflows/Build.yml @@ -0,0 +1,164 @@ +name: Build +run-name: ${{ github.ref_name }} build run + +on: + push: + tags: + - '*' + branches: + - main + repository_dispatch: + types: [run_build] + +permissions: + contents: write + +jobs: + + build-linux: + name: Linux (i686) + uses: ./.github/workflows/Linux.yml + + build-macos: + name: macOS + uses: ./.github/workflows/MacOS.yml + + build-windows-x64: + name: Windows x64 (MXE) + uses: ./.github/workflows/Windows-x64-MXE.yml + + #build-windows-arm64: + # name: Windows ARM64 (MSVC) + # uses: ./.github/workflows/Windows-ARM64.yml + + create-release: + name: Create Release + if: github.ref_type == 'tag' + runs-on: ubuntu-latest + needs: + #- build-linux + - build-macos + #- build-windows-x64 + #- build-windows-arm64 + defaults: + run: + shell: bash + + steps: + - name: Check Release Tag + id: check + run: | + : Check Release Tag + if [[ "${RUNNER_DEBUG}" ]]; then set -x; fi + shopt -s extglob + + # Accept any non-empty tag — the fork uses free-form tags like + # "octelys.1" whose version is built by combining with the + # upstream RA release number. Pre-release is flagged by the + # presence of -beta or -rc anywhere in the tag. + TAG="${GITHUB_REF_NAME}" + if [[ -z "$TAG" ]]; then + echo 'validTag=false' >> $GITHUB_OUTPUT + else + echo 'validTag=true' >> $GITHUB_OUTPUT + if [[ "$TAG" =~ -[bB]eta|-[rR][cC] ]]; then + echo 'prerelease=true' >> $GITHUB_OUTPUT + else + echo 'prerelease=false' >> $GITHUB_OUTPUT + fi + fi + + - name: Checkout repository + if: fromJSON(steps.check.outputs.validTag) + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Resolve upstream RetroArch version from git history + id: upstream + if: fromJSON(steps.check.outputs.validTag) + run: | + : Resolve upstream RetroArch version from git history + # Add upstream remote and fetch all release tags + git remote add upstream https://github.com/libretro/RetroArch.git + git fetch upstream 'refs/tags/v*:refs/tags/upstream-v*' --no-tags 2>/dev/null || true + + # Find the most recent upstream tag that is an ancestor of HEAD. + # This tells us exactly which RA release main was rebased onto. + RA_TAG=$(git tag --list 'upstream-v*' --sort=-version:refname \ + | while read -r t; do + if git merge-base --is-ancestor "$t" HEAD 2>/dev/null; then + echo "$t" + break + fi + done) + + # Strip the 'upstream-' prefix we added during fetch, then strip 'v' + RA_TAG="${RA_TAG#upstream-}" + RA_VERSION="${RA_TAG#v}" + + if [[ -z "$RA_VERSION" ]]; then + echo "::warning::Could not determine upstream RA version from git history. Falling back to API." + RA_VERSION=$(curl -fsSL \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${{ github.token }}" \ + https://api.github.com/repos/libretro/RetroArch/releases/latest \ + | jq -r '.tag_name // empty' | sed 's/^v//') + fi + + echo "ra_version=${RA_VERSION}" >> $GITHUB_OUTPUT + echo "Detected upstream RA version: ${RA_VERSION}" + + - name: Build combined version string + id: ver + if: fromJSON(steps.check.outputs.validTag) + run: | + : Build combined version string + MY_TAG="${GITHUB_REF_NAME}" + RA="${{ steps.upstream.outputs.ra_version }}" + COMBINED="${RA}-${MY_TAG}" + echo "version=${COMBINED}" >> $GITHUB_OUTPUT + + - name: Download Build Artifacts + uses: actions/download-artifact@v4 + if: fromJSON(steps.check.outputs.validTag) + id: download + + - name: Rename artifacts to versioned names + if: fromJSON(steps.check.outputs.validTag) + run: | + : Rename artifacts to versioned names + if [[ "${RUNNER_DEBUG}" ]]; then set -x; fi + shopt -s extglob + VERSION="${{ steps.ver.outputs.version }}" + for f in **/retroarch-macos-*.zip; do + dir=$(dirname "$f") + mv "$f" "${dir}/retroarch-macos-${VERSION}.zip" + done + + - name: Generate Checksums + if: fromJSON(steps.check.outputs.validTag) + run: | + : Generate Checksums + if [[ "${RUNNER_DEBUG}" ]]; then set -x; fi + shopt -s extglob + + echo "### Checksums" > ${{ github.workspace }}/CHECKSUMS.txt + for file in **/@(*.exe|*.tar.gz|*.zip); do + echo " ${file##*/}: $(sha256sum \"${file}\" | cut -d ' ' -f 1)" >> ${{ github.workspace }}/CHECKSUMS.txt + done + + - name: Create GitHub Release + if: fromJSON(steps.check.outputs.validTag) + uses: softprops/action-gh-release@9d7c94cfd0a1f3ed45544c887983e9fa900f0564 + with: + draft: false + prerelease: ${{ fromJSON(steps.check.outputs.prerelease) }} + tag_name: ${{ github.ref_name }} + name: RetroArch ${{ steps.ver.outputs.version }} + generate_release_notes: true + body_path: ${{ github.workspace }}/CHECKSUMS.txt + files: | + ${{ github.workspace }}/**/*.exe + ${{ github.workspace }}/**/*.zip + ${{ github.workspace }}/**/*.tar.gz diff --git a/.github/workflows/Linux.yml b/.github/workflows/Linux.yml index aeb9392f7645..0e590095022d 100644 --- a/.github/workflows/Linux.yml +++ b/.github/workflows/Linux.yml @@ -1,40 +1,81 @@ -name: CI Linux (i686) +name: CI Linux (x86_64) on: - #push: pull_request: repository_dispatch: types: [run_build] + workflow_call: permissions: contents: read -env: - ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: true - jobs: build: - runs-on: ubuntu-latest - container: - image: git.libretro.com:5050/libretro-infrastructure/libretro-build-i386-ubuntu:xenial-gcc9 - options: --user root + runs-on: ubuntu-24.04 steps: - - name: Check Out Repo - uses: taiki-e/checkout-action@v1 + - uses: actions/checkout@v6 - - name: Install libwebsockets - run: apt-get update && apt-get install -y libwebsockets-dev + - name: Install build dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + build-essential pkg-config \ + libasound2-dev libpulse-dev libsdl2-dev \ + libfreetype6-dev libx11-dev libxext-dev \ + libx11-xcb-dev \ + libxinerama-dev libxrandr-dev libxi-dev \ + libxss-dev libxxf86vm-dev libxkbcommon-dev \ + libudev-dev libgbm-dev libdrm-dev \ + libgl1-mesa-dev libegl1-mesa-dev \ + libvulkan-dev libwayland-dev wayland-protocols \ + liblzma-dev libmbedtls-dev libflac-dev \ + libass-dev libjack-jackd2-dev libv4l-dev \ + libwebsockets-dev libfontconfig1-dev \ + libdbus-1-dev zlib1g-dev \ + libespeak-ng-dev - name: Configure Build - run: | - ./configure --disable-qt --enable-xdelta + run: ./configure --disable-qt - name: Compile RA + run: make -j$(getconf _NPROCESSORS_ONLN) HAVE_DBUS=1 HAVE_TRANSLATE=1 HAVE_ACCESSIBILITY=1 HAVE_WEBSOCKET_SERVER=1 + + - name: Download companion assets + run: | + git clone --depth=1 https://github.com/libretro/retroarch-assets.git assets-repo + git clone --depth=1 https://github.com/libretro/libretro-database.git database-repo + git clone --depth=1 https://github.com/libretro/libretro-core-info.git core-info-repo + git clone --depth=1 https://github.com/libretro/retroarch-joypad-autoconfig.git joypad-repo + git clone --depth=1 https://github.com/libretro/slang-shaders.git slang-repo + git clone --depth=1 https://github.com/libretro/glsl-shaders.git glsl-repo + git clone --depth=1 https://github.com/libretro/common-overlays.git overlays-repo + + - name: Bundle release run: | - make -j$(getconf _NPROCESSORS_ONLN) clean - make -j$(getconf _NPROCESSORS_ONLN) info all + SHA=$(echo ${GITHUB_SHA} | cut -c1-8) + BUNDLE=retroarch-linux-x86_64-${SHA} + mkdir -p ${BUNDLE}/assets ${BUNDLE}/database ${BUNDLE}/info \ + ${BUNDLE}/autoconfig ${BUNDLE}/shaders/slang \ + ${BUNDLE}/shaders/glsl ${BUNDLE}/overlays + + cp retroarch ${BUNDLE}/ + + make -C assets-repo install PREFIX=$(pwd)/${BUNDLE}/assets 2>/dev/null || cp -r assets-repo/. ${BUNDLE}/assets/ + make -C database-repo install PREFIX=$(pwd)/${BUNDLE}/database 2>/dev/null || cp -r database-repo/. ${BUNDLE}/database/ + make -C core-info-repo install PREFIX=$(pwd)/${BUNDLE}/info 2>/dev/null || cp -r core-info-repo/. ${BUNDLE}/info/ + make -C joypad-repo install PREFIX=$(pwd)/${BUNDLE}/autoconfig 2>/dev/null || cp -r joypad-repo/. ${BUNDLE}/autoconfig/ + cp -r slang-repo/. ${BUNDLE}/shaders/slang/ + cp -r glsl-repo/. ${BUNDLE}/shaders/glsl/ + cp -r overlays-repo/. ${BUNDLE}/overlays/ + + tar -czf ${BUNDLE}.tar.gz ${BUNDLE}/ - name: Get short SHA id: slug - run: echo "::set-output name=sha8::$(echo ${GITHUB_SHA} | cut -c1-8)" + run: echo "sha8=$(echo ${GITHUB_SHA} | cut -c1-8)" >> $GITHUB_OUTPUT + + - uses: actions/upload-artifact@v4 + with: + name: retroarch-linux-x86_64-${{ steps.slug.outputs.sha8 }} + path: retroarch-linux-x86_64-${{ steps.slug.outputs.sha8 }}.tar.gz diff --git a/.github/workflows/MacOS.yml b/.github/workflows/MacOS.yml index f2cd3e53819e..f54b1038dfe2 100644 --- a/.github/workflows/MacOS.yml +++ b/.github/workflows/MacOS.yml @@ -1,15 +1,12 @@ name: CI macOS on: - #push: pull_request: + workflow_call: permissions: contents: read -env: - ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: true - jobs: build: runs-on: macos-latest @@ -31,12 +28,54 @@ jobs: set -o pipefail xcodebuild -workspace pkg/apple/RetroArch.xcworkspace -scheme RetroArch -config Release -xcconfig pkg/apple/GitHubCI.xcconfig -derivedDataPath build +# - name: Download companion assets +# run: | +# git clone --depth=1 https://github.com/libretro/retroarch-assets.git assets-repo +# git clone --depth=1 https://github.com/libretro/libretro-database.git database-repo +# git clone --depth=1 https://github.com/libretro/libretro-core-info.git core-info-repo +# git clone --depth=1 https://github.com/libretro/retroarch-joypad-autoconfig.git joypad-repo +# git clone --depth=1 https://github.com/libretro/slang-shaders.git slang-repo +# git clone --depth=1 https://github.com/libretro/glsl-shaders.git glsl-repo +# git clone --depth=1 https://github.com/libretro/common-overlays.git overlays-repo +# +# - name: Bundle release +# run: | +# SHA=$(echo ${GITHUB_SHA} | cut -c1-8) +# BUNDLE=retroarch-macos-${SHA} +# APP=build/Build/Products/Release/RetroArch.app +# mkdir -p ${BUNDLE} +# +# cp -r ${APP} ${BUNDLE}/ +# +# RESOURCES=${BUNDLE}/RetroArch.app/Contents/Resources +# mkdir -p ${RESOURCES}/assets ${RESOURCES}/database ${RESOURCES}/info \ +# ${RESOURCES}/autoconfig ${RESOURCES}/shaders/slang \ +# ${RESOURCES}/shaders/glsl ${RESOURCES}/overlays +# +# make -C assets-repo install PREFIX=$(pwd)/${RESOURCES}/assets 2>/dev/null || cp -r assets-repo/. ${RESOURCES}/assets/ +# make -C database-repo install PREFIX=$(pwd)/${RESOURCES}/database 2>/dev/null || cp -r database-repo/. ${RESOURCES}/database/ +# make -C core-info-repo install PREFIX=$(pwd)/${RESOURCES}/info 2>/dev/null || cp -r core-info-repo/. ${RESOURCES}/info/ +# make -C joypad-repo install PREFIX=$(pwd)/${RESOURCES}/autoconfig 2>/dev/null || cp -r joypad-repo/. ${RESOURCES}/autoconfig/ +# cp -r slang-repo/. ${RESOURCES}/shaders/slang/ +# cp -r glsl-repo/. ${RESOURCES}/shaders/glsl/ +# cp -r overlays-repo/. ${RESOURCES}/overlays/ +# +# zip -r ${BUNDLE}.zip ${BUNDLE}/ + - name: Get short SHA id: slug run: echo "sha8=$(echo ${GITHUB_SHA} | cut -c1-8)" >> $GITHUB_OUTPUT - - uses: actions/upload-artifact@v6 + - name: Bundle release + run: | + SHA=${{ steps.slug.outputs.sha8 }} + BUNDLE=retroarch-macos-${SHA} + APP=build/Build/Products/Release/RetroArch.app + mkdir -p ${BUNDLE} + cp -r ${APP} ${BUNDLE}/ + zip -r ${BUNDLE}.zip ${BUNDLE}/ + + - uses: actions/upload-artifact@v4 with: - name: RetroArch-${{ steps.slug.outputs.sha8 }} - path: | - build/Build/Products/Release + name: RetroArch-macos-${{ steps.slug.outputs.sha8 }} + path: retroarch-macos-${{ steps.slug.outputs.sha8 }}.zip diff --git a/.github/workflows/Windows-ARM64.yml b/.github/workflows/Windows-ARM64.yml index c9b5f346c4ad..67c5f8f8afdf 100644 --- a/.github/workflows/Windows-ARM64.yml +++ b/.github/workflows/Windows-ARM64.yml @@ -1,17 +1,14 @@ name: CI Windows ARM64 (MSVC) on: - #push: pull_request: repository_dispatch: types: [run_build] + workflow_call: permissions: contents: read -env: - ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: true - jobs: msvc: runs-on: windows-11-arm @@ -19,14 +16,23 @@ jobs: strategy: matrix: version: [2022] - configuration: [Debug, Release] + configuration: [Release] platform: [ARM64] steps: - uses: actions/checkout@v4 - name: Add msbuild to PATH - uses: microsoft/setup-msbuild@v1 + uses: microsoft/setup-msbuild@v2 + + # Cache vcpkg binary artefacts so OpenSSL/libwebsockets are not rebuilt every run + - name: Cache vcpkg binary cache + uses: actions/cache@v4 + with: + path: ~\AppData\Local\vcpkg\archives + key: vcpkg-arm64-windows-${{ hashFiles('**/vcpkg.json') }}-v1 + restore-keys: | + vcpkg-arm64-windows- - name: Install libwebsockets (vcpkg) shell: powershell @@ -50,8 +56,69 @@ jobs: shell: powershell run: echo "sha8=$('${{ github.sha }}'.Substring(0,8))" >> $env:GITHUB_OUTPUT + - name: Download companion assets + shell: bash + run: | + git clone --depth=1 https://github.com/libretro/retroarch-assets.git assets-repo + git clone --depth=1 https://github.com/libretro/libretro-database.git database-repo + git clone --depth=1 https://github.com/libretro/libretro-core-info.git core-info-repo + git clone --depth=1 https://github.com/libretro/retroarch-joypad-autoconfig.git joypad-repo + git clone --depth=1 https://github.com/libretro/slang-shaders.git slang-repo + git clone --depth=1 https://github.com/libretro/glsl-shaders.git glsl-repo + git clone --depth=1 https://github.com/libretro/common-overlays.git overlays-repo + + - name: Bundle release + shell: powershell + run: | + $sha = '${{ steps.slug.outputs.sha8 }}' + $bundle = "retroarch-windows-arm64-$sha" + $exe = "pkg/msvc/${{ matrix.platform }}/${{ matrix.configuration }}/RetroArch-msvc${{ matrix.version }}.exe" + + New-Item -ItemType Directory -Force $bundle | Out-Null + Copy-Item $exe "$bundle\RetroArch.exe" + + foreach ($pair in @( + @{ src='assets-repo'; dst='assets' }, + @{ src='database-repo'; dst='database' }, + @{ src='core-info-repo';dst='info' }, + @{ src='joypad-repo'; dst='autoconfig' }, + @{ src='slang-repo'; dst='shaders\slang'}, + @{ src='glsl-repo'; dst='shaders\glsl' }, + @{ src='overlays-repo'; dst='overlays' } + )) { + $dest = "$bundle\$($pair.dst)" + New-Item -ItemType Directory -Force $dest | Out-Null + Copy-Item -Path "$($pair.src)\*" -Destination $dest -Recurse -Force + } + + # 7z is preinstalled on GitHub runners and is much faster than Compress-Archive + 7z a -tzip -mx=1 "$bundle.zip" $bundle\ + + - name: Build installer + shell: powershell + run: | + $sha = '${{ steps.slug.outputs.sha8 }}' + $bundle = "retroarch-windows-arm64-$sha" + + if (-not (Get-Command makensis -ErrorAction SilentlyContinue)) { + choco install nsis --no-progress --yes + $env:PATH += ";C:\Program Files (x86)\NSIS" + } + + makensis ` + /DVERSION=$sha ` + /DARCH=arm64 ` + /DBUNDLE_DIR="$(Resolve-Path $bundle)" ` + pkg\windows\installer.nsi + + Move-Item "pkg\windows\$bundle-installer.exe" "$bundle-installer.exe" + + - uses: actions/upload-artifact@v4 + with: + name: retroarch-windows-arm64-${{ steps.slug.outputs.sha8 }} + path: retroarch-windows-arm64-${{ steps.slug.outputs.sha8 }}.zip + - uses: actions/upload-artifact@v4 with: - name: retroarch-${{ matrix.version }}-${{ matrix.configuration }}-${{ matrix.platform }}-${{ steps.slug.outputs.sha8 }} - path: | - pkg/msvc/${{ matrix.platform }}/${{ matrix.configuration }}/RetroArch-msvc${{ matrix.version }}.exe + name: retroarch-windows-arm64-${{ steps.slug.outputs.sha8 }}-installer + path: retroarch-windows-arm64-${{ steps.slug.outputs.sha8 }}-installer.exe diff --git a/.github/workflows/Windows-x64-MXE.yml b/.github/workflows/Windows-x64-MXE.yml index 8e8061faf281..c9360a60494c 100644 --- a/.github/workflows/Windows-x64-MXE.yml +++ b/.github/workflows/Windows-x64-MXE.yml @@ -1,50 +1,106 @@ -name: CI Windows x64 (MXE) +name: CI Windows x64 (MinGW) on: - #push: pull_request: repository_dispatch: types: [run_build] + workflow_call: permissions: contents: read -env: - ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: true - jobs: build: - runs-on: ubuntu-latest - container: - image: git.libretro.com:5050/libretro-infrastructure/libretro-build-mxe-win64-cross:gcc10 - options: --user root + runs-on: windows-latest + + defaults: + run: + shell: msys2 {0} steps: - - uses: taiki-e/checkout-action@v1 + - uses: actions/checkout@v4 - - name: Install MXE libwebsockets (optional) - run: | - if apt-get install -y mxe-x86-64-w64-mingw32.shared-libwebsockets; then - echo "MXE libwebsockets installed; staging into deps/libwebsockets/x64/" - mkdir -p deps/libwebsockets/include deps/libwebsockets/x64 - cp /usr/lib/mxe/usr/x86_64-w64-mingw32.shared/include/libwebsockets.h deps/libwebsockets/include/ || true - cp /usr/lib/mxe/usr/x86_64-w64-mingw32.shared/lib/libwebsockets.dll.a deps/libwebsockets/x64/ 2>/dev/null || \ - cp /usr/lib/mxe/usr/x86_64-w64-mingw32.shared/lib/libwebsockets.a deps/libwebsockets/x64/ 2>/dev/null || true - else - echo "MXE libwebsockets not available; building without WebSocket server support" - fi + - name: Set up MSYS2 / MinGW-w64 x86_64 + uses: msys2/setup-msys2@v2 + with: + msystem: MINGW64 + update: true + install: >- + base-devel + git + zip + mingw-w64-x86_64-toolchain + mingw-w64-x86_64-pkg-config + mingw-w64-x86_64-SDL2 + mingw-w64-x86_64-freetype + mingw-w64-x86_64-zlib + mingw-w64-x86_64-flac + mingw-w64-x86_64-mbedtls + mingw-w64-x86_64-libwebsockets + + - name: Configure + run: ./configure --disable-qt - name: Compile RA - run: | - export MOC=/usr/lib/mxe/usr/x86_64-w64-mingw32.shared/qt5/bin/moc - ./configure --host=x86_64-w64-mingw32.shared - make clean - if [ -f deps/libwebsockets/x64/libwebsockets.dll.a ] || [ -f deps/libwebsockets/x64/libwebsockets.a ]; then - make -j$(getconf _NPROCESSORS_ONLN) HAVE_WEBSOCKET_SERVER=1 - else - make -j$(getconf _NPROCESSORS_ONLN) - fi + run: make -j$(getconf _NPROCESSORS_ONLN) HAVE_WEBSOCKET_SERVER=1 - name: Get short SHA id: slug - run: echo "::set-output name=sha8::$(echo ${GITHUB_SHA} | cut -c1-8)" + run: echo "sha8=$(echo ${GITHUB_SHA} | cut -c1-8)" >> $GITHUB_OUTPUT + + - name: Download companion assets + run: | + git clone --depth=1 https://github.com/libretro/retroarch-assets.git assets-repo + git clone --depth=1 https://github.com/libretro/libretro-database.git database-repo + git clone --depth=1 https://github.com/libretro/libretro-core-info.git core-info-repo + git clone --depth=1 https://github.com/libretro/retroarch-joypad-autoconfig.git joypad-repo + git clone --depth=1 https://github.com/libretro/slang-shaders.git slang-repo + git clone --depth=1 https://github.com/libretro/glsl-shaders.git glsl-repo + git clone --depth=1 https://github.com/libretro/common-overlays.git overlays-repo + + - name: Bundle release + run: | + SHA=${{ steps.slug.outputs.sha8 }} + BUNDLE=retroarch-windows-x64-${SHA} + + mkdir -p ${BUNDLE} + cp retroarch.exe ${BUNDLE}/RetroArch.exe + + mkdir -p ${BUNDLE}/assets && cp -r assets-repo/. ${BUNDLE}/assets/ + mkdir -p ${BUNDLE}/database && cp -r database-repo/. ${BUNDLE}/database/ + mkdir -p ${BUNDLE}/info && cp -r core-info-repo/. ${BUNDLE}/info/ + mkdir -p ${BUNDLE}/autoconfig && cp -r joypad-repo/. ${BUNDLE}/autoconfig/ + mkdir -p ${BUNDLE}/shaders/slang && cp -r slang-repo/. ${BUNDLE}/shaders/slang/ + mkdir -p ${BUNDLE}/shaders/glsl && cp -r glsl-repo/. ${BUNDLE}/shaders/glsl/ + mkdir -p ${BUNDLE}/overlays && cp -r overlays-repo/. ${BUNDLE}/overlays/ + + zip -r ${BUNDLE}.zip ${BUNDLE}/ + + - name: Build installer + shell: powershell + run: | + $sha = '${{ steps.slug.outputs.sha8 }}' + $bundle = "retroarch-windows-x64-$sha" + + if (-not (Get-Command makensis -ErrorAction SilentlyContinue)) { + choco install nsis --no-progress --yes + $env:PATH += ";C:\Program Files (x86)\NSIS" + } + + makensis ` + /DVERSION=$sha ` + /DARCH=x64 ` + /DBUNDLE_DIR="$(Resolve-Path $bundle)" ` + pkg\windows\installer.nsi + + Move-Item "pkg\windows\$bundle-installer.exe" "$bundle-installer.exe" + + - uses: actions/upload-artifact@v4 + with: + name: retroarch-windows-x64-${{ steps.slug.outputs.sha8 }} + path: retroarch-windows-x64-${{ steps.slug.outputs.sha8 }}.zip + + - uses: actions/upload-artifact@v4 + with: + name: retroarch-windows-x64-${{ steps.slug.outputs.sha8 }}-installer + path: retroarch-windows-x64-${{ steps.slug.outputs.sha8 }}-installer.exe diff --git a/.github/workflows/upstream-sync.yml b/.github/workflows/upstream-sync.yml index 6a38c278616f..302b90d8f979 100644 --- a/.github/workflows/upstream-sync.yml +++ b/.github/workflows/upstream-sync.yml @@ -1,7 +1,13 @@ -# Daily sync of upstream libretro/RetroArch changes into this fork. -# Opens (or updates) a pull request targeting main each day. -# A branch is created from main, upstream/master is merged into it, -# and a PR is opened for review. +# Daily canary sync of upstream libretro/RetroArch master into this fork. +# +# Purpose: early breakage detection — NOT a source for merging into main. +# +# The sync branch (octelys/upstream-sync) contains: +# upstream/master commits + your feature commits from main (rebased on top) +# +# CI runs against this branch so you find out the next morning if upstream +# broke any of your features. Fix issues on your feature/* branches, then +# merge them into main. Never merge the sync PR itself into main. name: Upstream Sync @@ -52,21 +58,26 @@ jobs: run: | echo "Fork is already up to date with upstream. Nothing to do." - - name: Create sync branch from main and merge upstream + - name: Create sync branch: upstream/master with your features rebased on top if: steps.check.outputs.new_commits != '0' run: | - # Create (or reset) the sync branch from main - git checkout -B octelys/upstream-sync main - - # Merge upstream/master into the branch. - # On conflicts in .github/workflows, keep ours (the fork's version). - git merge upstream/master --no-edit \ - -X ours \ - --allow-unrelated-histories || true - - # Regardless of merge result, ensure .github/workflows matches main + # Start the sync branch from upstream/master (not from main) + git checkout -B octelys/upstream-sync upstream/master + + # Identify the commits on main that are not in the upstream baseline. + # These are your feature commits. We cherry-pick them on top of + # upstream/master so CI tests the combination. + # Find the merge-base between main and the previous upstream state + # so we only replay YOUR commits, not upstream ones. + FORK_BASE=$(git merge-base main upstream/master || true) + YOUR_COMMITS=$(git rev-list --reverse ${FORK_BASE}..main 2>/dev/null || true) + + if [[ -n "$YOUR_COMMITS" ]]; then + git cherry-pick --allow-empty --keep-redundant-commits $YOUR_COMMITS || true + fi + + # Always restore fork workflow files so upstream cannot overwrite them. git checkout main -- .github/workflows - # Only commit if there are staged changes to workflows git diff --cached --quiet || \ git commit -m "chore: preserve fork workflow files" @@ -82,24 +93,30 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} branch: octelys/upstream-sync base: main - title: "chore: sync upstream libretro/RetroArch (${{ steps.check.outputs.new_commits }} new commit(s))" + title: "chore: canary sync — upstream/master (${{ steps.check.outputs.new_commits }} new commit(s))" body: | - ## Upstream sync + ## Daily canary sync This PR was automatically generated by the **Upstream Sync** workflow. + **Do not merge this PR into `main`.** + + Its purpose is early breakage detection: it contains upstream `master` + commits with your feature commits from `main` rebased on top. If CI + passes, your features are compatible with the current upstream `master`. + If CI fails, fix the issue on your `feature/*` branches and merge into `main`. | | | |---|---| | **Upstream** | [`libretro/RetroArch@master`](https://github.com/libretro/RetroArch/tree/master) | | **Upstream SHA** | `${{ steps.check.outputs.upstream_sha }}` | - | **New commits** | ${{ steps.check.outputs.new_commits }} | + | **New upstream commits** | ${{ steps.check.outputs.new_commits }} | | **Generated at** | ${{ github.run_id }} | - ### Before merging - - Review the diff for any conflicts with fork-specific changes. - - Run the CI checks (Linux, macOS, Windows x64, Windows ARM64). + ### This PR will NOT be merged + - If CI is green: close the PR — your features are safe. + - If CI is red: fix the broken `feature/*` branch, merge it into `main`, and the next daily run will reopen a fresh PR. - > This PR is recreated or updated daily. Closing it without merging will result in a new one being opened the next day if upstream still has new commits. + > This PR is recreated or updated daily. labels: upstream-sync draft: false diff --git a/Makefile.common b/Makefile.common index 876a5f0e0cd5..2c47e71579ca 100644 --- a/Makefile.common +++ b/Makefile.common @@ -2963,6 +2963,7 @@ endif ifeq ($(HAVE_WEBSOCKET_SERVER), 1) DEFINES += -DHAVE_WEBSOCKET_SERVER OBJ += network/ws_server.o + OBJ += network/game_state.o ifneq ($(findstring Win32,$(OS)),) # Windows x64 / ARM64 – headers and import-lib live under diff --git a/cheevos/cheevos.c b/cheevos/cheevos.c index 8f889d6d6200..ca8279989465 100644 --- a/cheevos/cheevos.c +++ b/cheevos/cheevos.c @@ -82,6 +82,11 @@ #include "../deps/rcheevos/include/rc_hash.h" #include "../deps/rcheevos/src/rc_libretro.h" +#ifdef HAVE_WEBSOCKET_SERVER +#include "../network/game_state.h" +#include "../network/ws_server.h" +#endif + /* Define this macro to prevent cheevos from being deactivated when they trigger. */ #undef CHEEVOS_DONT_DEACTIVATE @@ -477,6 +482,12 @@ static void rcheevos_award_achievement(const rc_client_achievement_t* cheevo) } } #endif + +#ifdef HAVE_WEBSOCKET_SERVER + /* Broadcast the updated achievement list so connected clients + * immediately see the new unlock status. */ + ws_server_notify_achievements_changed(); +#endif } static void rcheevos_lboard_submitted(const rc_client_leaderboard_t* lboard, @@ -820,6 +831,12 @@ bool rcheevos_unload(void) rcheevos_locals.menuitem_count = 0; } #endif + +#ifdef HAVE_WEBSOCKET_SERVER + /* Force connected clients to refresh after unload so any queued + * pre-unload broadcast is corrected with an empty achievements list. */ + ws_server_notify_achievements_changed(); +#endif } #ifdef HAVE_THREADS @@ -1162,6 +1179,7 @@ const char* rcheevos_get_hash(void) return game ? game->hash : msg_hash_to_str(MENU_ENUM_LABEL_VALUE_NOT_AVAILABLE); } + /* hooks for rc_hash library */ static void* rc_hash_handle_file_open(const char* path) @@ -1503,6 +1521,12 @@ static void rcheevos_client_login_callback(int result, runloop_msg_queue_push(msg, _len, 0, 2 * 60, false, NULL, MESSAGE_QUEUE_ICON_DEFAULT, MESSAGE_QUEUE_CATEGORY_INFO); } + +#ifdef HAVE_WEBSOCKET_SERVER + /* Broadcast the user info to any already-connected WebSocket clients. */ + game_state_set_user_from_cheevos(user); + ws_server_notify_user_changed(); +#endif } } @@ -1716,6 +1740,12 @@ static void rcheevos_client_load_game_callback(int result, rcheevos_spectating_changed(); /* synchronize spectating state */ +#ifdef HAVE_WEBSOCKET_SERVER + /* Now that the async RA lookup is complete, hand everything to + * game_state — it builds and broadcasts the full record. */ + game_state_update_from_cheevos(game, path_get(RARCH_PATH_CONTENT)); +#endif + #ifdef HAVE_THREADS /* Have to "schedule" this. Game image should not be * loaded into memory on background thread */ diff --git a/griffin/griffin.c b/griffin/griffin.c index b7aa7cc2e82b..66c21b3fe2e1 100644 --- a/griffin/griffin.c +++ b/griffin/griffin.c @@ -1711,6 +1711,7 @@ STEAM INTEGRATION USING MIST #endif #if defined(HAVE_WEBSOCKET_SERVER) && !defined(_MSC_VER) +#include "../network/game_state.c" #include "../network/ws_server.c" #endif diff --git a/griffin/griffin_cpp.cpp b/griffin/griffin_cpp.cpp index 83dc9d860a3f..57995257b18e 100644 --- a/griffin/griffin_cpp.cpp +++ b/griffin/griffin_cpp.cpp @@ -124,5 +124,6 @@ FONTS WEBSOCKET SERVER (MSVC only – compiled as C++ to support OpenSSL 3.x headers) ============================================================ */ #if defined(HAVE_WEBSOCKET_SERVER) && defined(_MSC_VER) +#include "../network/game_state.c" #include "../network/ws_server.c" #endif diff --git a/network/game_state.c b/network/game_state.c new file mode 100644 index 000000000000..d972304adb9e --- /dev/null +++ b/network/game_state.c @@ -0,0 +1,587 @@ +/* RetroArch - A frontend for libretro. + * Copyright (C) 2024 - libretro team + * + * RetroArch is free software: you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * + * RetroArch is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with RetroArch. If not, see . + */ + +/* + * game_state.c – Thread-safe in-memory store for the currently active game. + * + * The module holds a single ra_game_state_t record plus a boolean flag + * that indicates whether a game is running. All public functions + * serialise access through a mutex so they may be called from any thread. + */ + +#include "game_state.h" + +#include +#include +#include +#include + +#include "../playlist.h" + +#include +#include + + +#define GAME_STATE_TAG "[game_state] " + +#ifdef HAVE_CHEEVOS +#include "../deps/rcheevos/include/rc_client.h" +#include "../deps/rcheevos/include/rc_consoles.h" +#include "ws_server.h" +#endif + +/* ------------------------------------------------------------------------- + * Internal state + * ---------------------------------------------------------------------- */ + +static slock_t *g_state_lock = NULL; +static ra_game_state_t g_state; +static bool g_is_running = false; + +#ifdef HAVE_CHEEVOS +/* Logged-in RetroAchievements user (set once on login). */ +typedef struct +{ + char username [128]; + char display_name[128]; + char avatar_url [512]; + uint32_t score; + uint32_t score_softcore; + bool is_logged_in; +} ra_user_state_t; + +static ra_user_state_t g_user_state; +#endif + +/* ------------------------------------------------------------------------- + * Lifecycle + * ---------------------------------------------------------------------- */ + +void game_state_init(void) +{ + if (g_state_lock) + return; /* already initialised */ + g_state_lock = slock_new(); + memset(&g_state, 0, sizeof(g_state)); + g_is_running = false; +#ifdef HAVE_CHEEVOS + memset(&g_user_state, 0, sizeof(g_user_state)); +#endif +} + +void game_state_deinit(void) +{ + if (!g_state_lock) + return; + slock_free(g_state_lock); + g_state_lock = NULL; + g_is_running = false; +} + +/* ------------------------------------------------------------------------- + * Helpers + * ---------------------------------------------------------------------- */ + +/** + * json_append_field: + * @buf : destination buffer. + * @pos : current write offset; updated on return. + * @buf_size : total size of @buf. + * @key : JSON key (must not contain characters needing escaping). + * @value : value string to escape and append. + * + * Appends ,"key":"escaped-value" to @buf starting at *pos. + * Updates *pos. Silently truncates if the buffer is too small. + */ +static void json_append_field(char *buf, size_t *pos, + size_t buf_size, const char *key, const char *value) +{ + /* Write the key prefix */ + int n = snprintf(buf + *pos, buf_size - *pos, ",\"%s\":\"", key); + if (n > 0) + *pos += (size_t)n; + + /* Escape and copy the value character-by-character. + * Reserve 2 bytes for a potential 2-byte escape sequence ('\\' + char); + * the closing '"' is appended separately after the loop. */ + if (!string_is_empty(value)) + { + const unsigned char *src = (const unsigned char *)value; + while (*src && *pos + 2 < buf_size) + { + unsigned char c = *src++; + if (c == '"' || c == '\\') + { + buf[(*pos)++] = '\\'; + buf[(*pos)++] = (char)c; + } + else if (c >= 0x20) /* skip bare control characters */ + buf[(*pos)++] = (char)c; + } + } + + /* Close the quoted value */ + if (*pos + 1 < buf_size) + buf[(*pos)++] = '"'; +} + +/* ------------------------------------------------------------------------- + * Public API + * ---------------------------------------------------------------------- */ + +void game_state_set(const ra_game_state_t *state) +{ + if (!state || !g_state_lock) + return; + slock_lock(g_state_lock); + g_state = *state; + g_is_running = true; + slock_unlock(g_state_lock); +} + +void game_state_clear(void) +{ + if (!g_state_lock) + return; + slock_lock(g_state_lock); + memset(&g_state, 0, sizeof(g_state)); + g_is_running = false; + slock_unlock(g_state_lock); +} + +bool game_state_is_running(void) +{ + bool running; + if (!g_state_lock) + return false; + slock_lock(g_state_lock); + running = g_is_running; + slock_unlock(g_state_lock); + return running; +} + +bool game_state_get(ra_game_state_t *out) +{ + bool running; + if (!out || !g_state_lock) + return false; + slock_lock(g_state_lock); + running = g_is_running; + if (running) + *out = g_state; + slock_unlock(g_state_lock); + return running; +} + +size_t game_state_to_json(char *buf, size_t buf_size) +{ + ra_game_state_t snap; + bool running; + size_t pos = 0; + int n; + + if (!buf || buf_size < 2) + return 0; + + memset(&snap, 0, sizeof(snap)); + + if (g_state_lock) + { + slock_lock(g_state_lock); + running = g_is_running; + if (running) + snap = g_state; + slock_unlock(g_state_lock); + } + else + { + running = false; + } + + if (!running) + { + n = snprintf(buf, buf_size, "{\"type\":\"no_game\"}"); + return (n > 0) ? (size_t)n : 0; + } + + /* Open object and write the type field */ + n = snprintf(buf, buf_size, "{\"type\":\"game_playing\""); + if (n <= 0) + return 0; + pos = (size_t)n; + + json_append_field(buf, &pos, buf_size, "game_id", snap.game_id); + json_append_field(buf, &pos, buf_size, "game_name", snap.game_name); + json_append_field(buf, &pos, buf_size, "console_id", snap.console_id); + json_append_field(buf, &pos, buf_size, "console_name", snap.console_name); + json_append_field(buf, &pos, buf_size, "cover_url", snap.cover_url); + + /* Close object */ + if (pos + 1 < buf_size) + { + buf[pos++] = '}'; + buf[pos] = '\0'; + } + + return pos; +} + +#ifdef HAVE_CHEEVOS +/** + * game_state_update_from_cheevos: + * + * Sole entry-point for populating and broadcasting the WebSocket game + * state. Called from rcheevos_client_load_game_callback() once the + * async RetroAchievements lookup has completed. Builds a fresh + * ra_game_state_t from RA data: + * + * id → game_id (authoritative numeric RA game ID) + * title → game_name (RA-canonical title) + * console_id → console_name (human-readable via rc_console_name()) + * playlist entry → cover_url (libretro thumbnails boxart URL from label + db_name) + */ +void game_state_update_from_cheevos(const rc_client_game_t *game, + const char *game_path) +{ + ra_game_state_t state; + const struct playlist_entry *entry = NULL; + const char *db_src = NULL; + + if (!game || game->id == 0) + return; + + memset(&state, 0, sizeof(state)); + + /* Authoritative numeric RA game ID */ + snprintf(state.game_id, sizeof(state.game_id), "%u", (unsigned)game->id); + + /* RA-canonical game title */ + if (!string_is_empty(game->title)) + strlcpy(state.game_name, game->title, sizeof(state.game_name)); + + /* Human-readable console name */ + if (game->console_id != 0) + { + const char *con = rc_console_name(game->console_id); + if (!string_is_empty(con)) + strlcpy(state.console_name, con, sizeof(state.console_name)); + } + + /* Look up the playlist entry for this ROM to get the label and + * db_name used by the libretro thumbnail server. */ + if (!string_is_empty(game_path)) + { + playlist_t *pl = playlist_get_cached(); + fprintf(stderr, GAME_STATE_TAG "game_path: \"%s\"\n", game_path); + fprintf(stderr, GAME_STATE_TAG "cached playlist: %s\n", pl ? "found" : "NULL"); + if (pl) + { + playlist_get_index_by_path(pl, game_path, &entry); + fprintf(stderr, GAME_STATE_TAG "playlist entry: %s\n", entry ? "found" : "not found"); + if (entry) + { + fprintf(stderr, GAME_STATE_TAG " entry->label: \"%s\"\n", + entry->label ? entry->label : "(null)"); + fprintf(stderr, GAME_STATE_TAG " entry->db_name: \"%s\"\n", + entry->db_name ? entry->db_name : "(null)"); + } + } + } + else + fprintf(stderr, GAME_STATE_TAG "game_path: (empty)\n"); + + /* System folder = db_name without ".lpl" extension. + * Falls back to the RA console name when no playlist entry exists. */ + if (entry && !string_is_empty(entry->db_name)) + db_src = entry->db_name; + else + db_src = state.console_name; + + fprintf(stderr, GAME_STATE_TAG "db_src: \"%s\"\n", db_src ? db_src : "(null)"); + + /* Build the cover URL from the playlist label and db_name: + * https://thumbnails.libretro.com//Named_Boxarts/