diff --git a/.github/workflows/update-fexcore.yml b/.github/workflows/update-fexcore.yml
new file mode 100644
index 0000000000..71dcfc3686
--- /dev/null
+++ b/.github/workflows/update-fexcore.yml
@@ -0,0 +1,71 @@
+name: Update FEXCore
+
+on:
+ schedule:
+ - cron: '0 6 * * *'
+ workflow_dispatch:
+
+permissions:
+ contents: write
+ pull-requests: write
+
+jobs:
+ update-fexcore:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install zstd
+ run: sudo apt-get install -y zstd
+
+ - name: Find latest FEXCore .wcp
+ id: check
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: python3 tools/check-latest-fexcore.py
+
+ - name: Download ${{ steps.check.outputs.LATEST_FILE }}
+ if: steps.check.outputs.ALREADY_EXISTS == 'false'
+ run: |
+ curl -fL -o latest.wcp \
+ "https://raw.githubusercontent.com/StevenMXZ/Winlator-Contents/main/FEXCore/${{ steps.check.outputs.LATEST_FILE }}"
+
+ - name: Convert .wcp → .tzst
+ if: steps.check.outputs.ALREADY_EXISTS == 'false'
+ run: |
+ chmod +x tools/convert-wcp-to-tzst.sh
+ ./tools/convert-wcp-to-tzst.sh latest.wcp "${{ steps.check.outputs.TZST_PATH }}"
+
+ - name: Update arrays.xml
+ if: steps.check.outputs.ALREADY_EXISTS == 'false'
+ run: |
+ python3 tools/update-arrays-xml.py \
+ app/src/main/res/values/arrays.xml \
+ "${{ steps.check.outputs.VERSION }}"
+
+ - name: Create PR branch, commit, and open PR
+ if: steps.check.outputs.ALREADY_EXISTS == 'false'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ VERSION="${{ steps.check.outputs.VERSION }}"
+ BRANCH="fexcore/update-${VERSION}"
+
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+
+ git checkout -b "$BRANCH"
+ git add "${{ steps.check.outputs.TZST_PATH }}"
+ git add app/src/main/res/values/arrays.xml
+ git commit -m "chore: add FEXCore ${VERSION}"
+ git push origin "$BRANCH"
+
+ gh pr create \
+ --title "chore: add FEXCore ${VERSION}" \
+ --body "Automated update from [StevenMXZ/Winlator-Contents](https://github.com/StevenMXZ/Winlator-Contents/tree/main/FEXCore).
+
+ - Converted \`${{ steps.check.outputs.LATEST_FILE }}\` → \`${{ steps.check.outputs.TZST_PATH }}\`
+ - Added \`${VERSION}\` to \`fexcore_version_entries\` in \`arrays.xml\`" \
+ --head "$BRANCH" \
+ --base "$(git remote show origin | awk '/HEAD branch/ {print $NF}')"
diff --git a/app/src/main/assets/fexcore/fexcore-2604.tzst b/app/src/main/assets/fexcore/fexcore-2604.tzst
new file mode 100644
index 0000000000..ea9fe600e6
Binary files /dev/null and b/app/src/main/assets/fexcore/fexcore-2604.tzst differ
diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml
index c5339720c8..805c8ef12d 100644
--- a/app/src/main/res/values/arrays.xml
+++ b/app/src/main/res/values/arrays.xml
@@ -195,6 +195,7 @@
- 2512
- 2601
- 2603
+ - 2604
- Standard (Old Gamepads)
diff --git a/tools/check-latest-fexcore.py b/tools/check-latest-fexcore.py
new file mode 100644
index 0000000000..4b5919818c
--- /dev/null
+++ b/tools/check-latest-fexcore.py
@@ -0,0 +1,121 @@
+#!/usr/bin/env python3
+"""
+check-latest-fexcore.py
+Queries the StevenMXZ/Winlator-Contents FEXCore directory and prints the
+latest .wcp filename (by YYMM version number).
+
+Usage:
+ python3 tools/check-latest-fexcore.py [--token ]
+
+A token is optional but recommended to avoid the 60 req/hr anonymous rate limit.
+It can also be supplied via the GITHUB_TOKEN environment variable.
+"""
+
+import argparse
+import json
+import os
+import re
+import subprocess
+import sys
+
+API_URL = "https://api.github.com/repos/StevenMXZ/Winlator-Contents/contents/FEXCore"
+
+
+def fetch_listing(token: str | None) -> list[dict]:
+ cmd = [
+ "curl", "-sf",
+ "-H", "Accept: application/vnd.github.v3+json",
+ ]
+ if token:
+ cmd += ["-H", f"Authorization: Bearer {token}"]
+ cmd.append(API_URL)
+
+ result = subprocess.run(cmd, capture_output=True, text=True)
+ if result.returncode != 0:
+ print(f"curl error: {result.stderr.strip()}", file=sys.stderr)
+ sys.exit(1)
+ return json.loads(result.stdout)
+
+
+def pick_latest(entries: list[dict]) -> tuple[str | None, tuple[int, int]]:
+ best_name = None
+ best_ver: tuple[int, int] = (-1, -1)
+ for entry in entries:
+ if entry.get("type") != "file":
+ continue
+ name = entry["name"]
+ if not name.endswith(".wcp"):
+ continue
+ m = re.match(r"^(\d{4})(?:\.(\d+))?\.wcp$", name)
+ if m:
+ ver = (int(m.group(1)), int(m.group(2) or 0))
+ if ver > best_ver:
+ best_ver = ver
+ best_name = name
+ return best_name, best_ver
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="Find the latest FEXCore .wcp release.")
+ parser.add_argument("--token", default=os.environ.get("GITHUB_TOKEN"), help="GitHub token (or set GITHUB_TOKEN)")
+ parser.add_argument(
+ "--gha-output",
+ metavar="FILE",
+ default=os.environ.get("GITHUB_OUTPUT"),
+ help="Append key=value pairs to this file (GitHub Actions $GITHUB_OUTPUT). "
+ "Automatically set when GITHUB_OUTPUT env var is present.",
+ )
+ args = parser.parse_args()
+
+ if not args.gha_output:
+ # Human-readable mode
+ print(f"Querying {API_URL} ...")
+
+ entries = fetch_listing(args.token)
+
+ all_wcp = [e["name"] for e in entries if e.get("type") == "file" and e["name"].endswith(".wcp")]
+
+ latest, ver = pick_latest(entries)
+ if not latest:
+ print("ERROR: Could not determine latest versioned .wcp file.", file=sys.stderr)
+ sys.exit(1)
+
+ version = latest[:4] # first four digits = YYMM
+ tzst_path = f"app/src/main/assets/fexcore/fexcore-{version}.tzst"
+ download_url = f"https://raw.githubusercontent.com/StevenMXZ/Winlator-Contents/main/FEXCore/{latest}"
+
+ # Use arrays.xml as the source of truth — it only contains a version once the
+ # full workflow has completed and committed, unlike the .tzst which can exist
+ # locally from test runs (e.g. act --bind).
+ arrays_xml = "app/src/main/res/values/arrays.xml"
+ try:
+ with open(arrays_xml, "r", encoding="utf-8") as f:
+ already_exists = f"- {version}
" in f.read()
+ except FileNotFoundError:
+ already_exists = False
+
+ if args.gha_output:
+ # GitHub Actions mode: write outputs, print minimal log to stdout
+ with open(args.gha_output, "a", encoding="utf-8") as f:
+ f.write(f"LATEST_FILE={latest}\n")
+ f.write(f"VERSION={version}\n")
+ f.write(f"TZST_PATH={tzst_path}\n")
+ f.write(f"ALREADY_EXISTS={'true' if already_exists else 'false'}\n")
+ if already_exists:
+ print(f"fexcore-{version}.tzst already present — nothing to do.")
+ else:
+ print(f"New release found: {latest} → {tzst_path}")
+ else:
+ # Human-readable mode
+ print(f"\nAll .wcp files found ({len(all_wcp)}):")
+ for name in sorted(all_wcp):
+ print(f" {name}")
+ print(f"\nLatest : {latest} (parsed version {ver[0]}.{ver[1]})")
+ print(f"VERSION: {version}")
+ print(f"Output : {tzst_path}")
+ print(f"Already exists: {already_exists}")
+ print(f"Download URL: {download_url}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tools/convert-wcp-to-tzst.sh b/tools/convert-wcp-to-tzst.sh
new file mode 100755
index 0000000000..541fdc527a
--- /dev/null
+++ b/tools/convert-wcp-to-tzst.sh
@@ -0,0 +1,54 @@
+#!/usr/bin/env bash
+# convert-wcp-to-tzst.sh
+# Converts a FEX .wcp release (XZ-compressed tar) into a fexcore .tzst file
+# compatible with the GameNative app assets format.
+#
+# Usage: ./tools/convert-wcp-to-tzst.sh
+# Example: ./tools/convert-wcp-to-tzst.sh FEX-2603.wcp app/src/main/assets/fexcore/fexcore-2603.tzst
+
+set -euo pipefail
+
+INPUT="${1:-}"
+OUTPUT="${2:-}"
+
+if [[ -z "$INPUT" || -z "$OUTPUT" ]]; then
+ echo "Usage: $0 "
+ exit 1
+fi
+
+if [[ ! -f "$INPUT" ]]; then
+ echo "Error: input file '$INPUT' not found"
+ exit 1
+fi
+
+# Ensure output directory exists
+mkdir -p "$(dirname "$OUTPUT")"
+
+echo "Converting '$INPUT' -> '$OUTPUT' ..."
+
+# Decompress XZ, extract only the two DLLs from system32/, strip the
+# system32/ path component, then repack as a zstd-compressed tar.
+#
+# The reference format (fexcore-2601.tzst) contains:
+# ./libwow64fex.dll
+# ./libarm64ecfex.dll
+# The .wcp source contains them under system32/, so we use --strip-components=1.
+
+TMPDIR="$(mktemp -d)"
+trap 'rm -rf "$TMPDIR"' EXIT
+
+echo " Extracting DLLs from archive..."
+xz -dc "$INPUT" \
+ | tar -x \
+ --strip-components=1 \
+ -C "$TMPDIR" \
+ --wildcards \
+ '*/libwow64fex.dll' \
+ '*/libarm64ecfex.dll'
+
+echo " Repacking as zstd tar..."
+# Use compression level 19 for smallest output (matches prior releases in size range).
+tar -c -C "$TMPDIR" . \
+ | zstd -19 -o "$OUTPUT" --force
+
+echo "Done: $(du -sh "$OUTPUT" | cut -f1) $OUTPUT"
diff --git a/tools/update-arrays-xml.py b/tools/update-arrays-xml.py
new file mode 100644
index 0000000000..7a797c2979
--- /dev/null
+++ b/tools/update-arrays-xml.py
@@ -0,0 +1,46 @@
+#!/usr/bin/env python3
+"""
+update-arrays-xml.py
+Appends a new version entry to the fexcore_version_entries array in arrays.xml.
+
+Usage:
+ python3 tools/update-arrays-xml.py
+ e.g. python3 tools/update-arrays-xml.py app/src/main/res/values/arrays.xml 2604
+"""
+
+import re
+import sys
+
+
+def main() -> None:
+ if len(sys.argv) != 3:
+ print(f"Usage: {sys.argv[0]} ", file=sys.stderr)
+ sys.exit(1)
+
+ path, version = sys.argv[1], sys.argv[2]
+
+ with open(path, "r", encoding="utf-8") as f:
+ content = f.read()
+
+ # Idempotency check
+ if f"- {version}
" in content:
+ print(f"Version {version} already present in arrays.xml — nothing to do.")
+ sys.exit(0)
+
+ # Find the last in the fexcore_version_entries block and insert after it.
+ pattern = r'(name="fexcore_version_entries".*?)(\s*)'
+ replacement = r'\g<1>\n - ' + version + r'
\g<2>'
+ new_content, count = re.subn(pattern, replacement, content, count=1, flags=re.DOTALL)
+
+ if count == 0:
+ print("ERROR: fexcore_version_entries array not found in arrays.xml", file=sys.stderr)
+ sys.exit(1)
+
+ with open(path, "w", encoding="utf-8") as f:
+ f.write(new_content)
+
+ print(f"Appended - {version}
to fexcore_version_entries.")
+
+
+if __name__ == "__main__":
+ main()