diff --git a/.github/workflows/update-fexcore.yml b/.github/workflows/update-fexcore.yml
new file mode 100644
index 0000000000..301d3d730b
--- /dev/null
+++ b/.github/workflows/update-fexcore.yml
@@ -0,0 +1,149 @@
+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:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ # Fetch the file listing from the upstream repo via the GitHub API.
+ # Authenticate to avoid anonymous rate-limit (60 req/hr).
+ RESPONSE=$(curl -sf \
+ -H "Accept: application/vnd.github.v3+json" \
+ -H "Authorization: Bearer $GH_TOKEN" \
+ "https://api.github.com/repos/StevenMXZ/Winlator-Contents/contents/FEXCore")
+
+ # Pick the file with the highest YYMM version.
+ # Only considers files whose names start with 4 digits (e.g. 2604.wcp, 2508.1.wcp).
+ LATEST=$(python3 - <<'EOF'
+ import sys, json, re
+
+ data = json.loads(sys.stdin.read())
+ files = [f["name"] for f in data if f["type"] == "file" and f["name"].endswith(".wcp")]
+
+ best = None
+ best_ver = (-1, -1)
+ for name in files:
+ 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
+
+ print(best or "")
+ EOF
+ <<< "$RESPONSE")
+
+ if [[ -z "$LATEST" ]]; then
+ echo "Could not determine latest .wcp file." >&2
+ exit 1
+ fi
+
+ # Extract the YYMM version (first four digits) for the output filename.
+ VERSION=$(echo "$LATEST" | grep -oP '^\d{4}')
+ TZST_PATH="app/src/main/assets/fexcore/fexcore-${VERSION}.tzst"
+
+ echo "LATEST_FILE=$LATEST" >> "$GITHUB_OUTPUT"
+ echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT"
+ echo "TZST_PATH=$TZST_PATH" >> "$GITHUB_OUTPUT"
+
+ if [[ -f "$TZST_PATH" ]]; then
+ echo "ALREADY_EXISTS=true" >> "$GITHUB_OUTPUT"
+ echo "fexcore-${VERSION}.tzst already present — nothing to do."
+ else
+ echo "ALREADY_EXISTS=false" >> "$GITHUB_OUTPUT"
+ echo "New release found: $LATEST → $TZST_PATH"
+ fi
+
+ - 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: |
+ VERSION="${{ steps.check.outputs.VERSION }}"
+ ARRAYS_XML="app/src/main/res/values/arrays.xml"
+
+ # Check if this version is already listed in arrays.xml.
+ if grep -qF "- ${VERSION}
" "$ARRAYS_XML"; then
+ echo "Version $VERSION already present in arrays.xml — skipping XML update."
+ else
+ # Insert the new version as the last - of fexcore_version_entries.
+ # We find the closing that follows the fexcore_version_entries
+ # block and insert directly before it.
+ python3 - "$ARRAYS_XML" "$VERSION" <<'PYEOF'
+ import sys, re
+
+ path, version = sys.argv[1], sys.argv[2]
+ with open(path, "r", encoding="utf-8") as f:
+ content = f.read()
+
+ # Find the fexcore_version_entries block and append the new item before its closing tag.
+ pattern = r'(name="fexcore_version_entries".*?)()'
+ replacement = r'\g<1>
- ' + version + r'
\n \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.")
+ PYEOF
+ fi
+
+ - 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/THIRD_PARTY_NOTICES b/THIRD_PARTY_NOTICES
index 46bc7ba44b..e123f334ab 100644
--- a/THIRD_PARTY_NOTICES
+++ b/THIRD_PARTY_NOTICES
@@ -32,3 +32,40 @@ You can copy, modify, distribute and perform the work, even for commercial
purposes, all without asking permission.
Full license text: https://creativecommons.org/publicdomain/zero/1.0/
+
+---
+
+## OpenAL Soft
+
+**Library:** OpenAL Soft v1.25.1
+**Author:** kcat (Chris Robinson) and contributors
+**Source:** https://openal-soft.org/ / https://github.com/kcat/openal-soft
+**License:** LGPL-2.1 (GNU Lesser General Public License v2.1)
+
+Pre-compiled Windows DLLs (openal32.dll, soft_oal.dll) for both x86 and
+x86_64 architectures are included in `app/src/main/assets/wincomponents/openal.tzst`.
+These DLLs are extracted at runtime only when the user explicitly sets the
+`WINEDLLOVERRIDES` environment variable to reference openal32 or soft_oal.
+
+As required by LGPL-2.1, the complete source code for the version used is
+available at:
+ https://github.com/kcat/openal-soft/releases/tag/1.25.1
+
+Users may substitute their own build of OpenAL Soft by replacing the DLLs
+in the wineprefix `drive_c/windows/system32/` and `drive_c/windows/syswow64/`
+directories.
+
+### License Text: LGPL-2.1
+
+This library is free software; you can redistribute it and/or modify it
+under the terms of the GNU Lesser General Public License as published by
+the Free Software Foundation; either version 2.1 of the License, or (at
+your option) any later version.
+
+This library 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 Lesser General Public License
+for more details.
+
+Full license text: https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html
+
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index d62165dd28..b4b5b1c5c5 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -52,7 +52,7 @@ android {
minSdk = 26
targetSdk = 28
- versionCode = 13
+ versionCode = 14
versionName = "0.9.0"
buildConfigField("boolean", "GOLD", "false")
diff --git a/app/schemas/app.gamenative.db.PluviaDatabase/19.json b/app/schemas/app.gamenative.db.PluviaDatabase/19.json
new file mode 100644
index 0000000000..2f632917ea
--- /dev/null
+++ b/app/schemas/app.gamenative.db.PluviaDatabase/19.json
@@ -0,0 +1,1260 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 19,
+ "identityHash": "c5f7f4b4360bc641147c4e3772e07792",
+ "entities": [
+ {
+ "tableName": "app_info",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `is_downloaded` INTEGER NOT NULL, `downloaded_depots` TEXT NOT NULL, `dlc_depots` TEXT NOT NULL, `branch` TEXT NOT NULL DEFAULT 'public', `recovered_install_size_bytes` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isDownloaded",
+ "columnName": "is_downloaded",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "downloadedDepots",
+ "columnName": "downloaded_depots",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dlcDepots",
+ "columnName": "dlc_depots",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "branch",
+ "columnName": "branch",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'public'"
+ },
+ {
+ "fieldPath": "recoveredInstallSizeBytes",
+ "columnName": "recovered_install_size_bytes",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "cached_license",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `license_json` TEXT NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "licenseJson",
+ "columnName": "license_json",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "app_change_numbers",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`appId` INTEGER, `changeNumber` INTEGER, PRIMARY KEY(`appId`))",
+ "fields": [
+ {
+ "fieldPath": "appId",
+ "columnName": "appId",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "changeNumber",
+ "columnName": "changeNumber",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "appId"
+ ]
+ }
+ },
+ {
+ "tableName": "encrypted_app_ticket",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`app_id` INTEGER NOT NULL, `result` INTEGER NOT NULL, `ticket_version_no` INTEGER NOT NULL, `crc_encrypted_ticket` INTEGER NOT NULL, `cb_encrypted_user_data` INTEGER NOT NULL, `cb_encrypted_app_ownership_ticket` INTEGER NOT NULL, `encrypted_ticket` BLOB NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`app_id`))",
+ "fields": [
+ {
+ "fieldPath": "appId",
+ "columnName": "app_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "result",
+ "columnName": "result",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "ticketVersionNo",
+ "columnName": "ticket_version_no",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "crcEncryptedTicket",
+ "columnName": "crc_encrypted_ticket",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "cbEncryptedUserData",
+ "columnName": "cb_encrypted_user_data",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "cbEncryptedAppOwnershipTicket",
+ "columnName": "cb_encrypted_app_ownership_ticket",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "encryptedTicket",
+ "columnName": "encrypted_ticket",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "app_id"
+ ]
+ }
+ },
+ {
+ "tableName": "app_file_change_lists",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`appId` INTEGER, `userFileInfo` TEXT NOT NULL, PRIMARY KEY(`appId`))",
+ "fields": [
+ {
+ "fieldPath": "appId",
+ "columnName": "appId",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "userFileInfo",
+ "columnName": "userFileInfo",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "appId"
+ ]
+ }
+ },
+ {
+ "tableName": "steam_app",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `package_id` INTEGER NOT NULL, `owner_account_id` TEXT NOT NULL, `license_flags` INTEGER NOT NULL, `received_pics` INTEGER NOT NULL, `last_change_number` INTEGER NOT NULL, `ufs_parse_version` INTEGER NOT NULL DEFAULT 0, `depots` TEXT NOT NULL, `branches` TEXT NOT NULL, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `os_list` INTEGER NOT NULL, `release_state` INTEGER NOT NULL, `release_date` INTEGER NOT NULL, `metacritic_score` INTEGER NOT NULL, `metacritic_full_url` TEXT NOT NULL, `logo_hash` TEXT NOT NULL, `logo_small_hash` TEXT NOT NULL, `icon_hash` TEXT NOT NULL, `client_icon_hash` TEXT NOT NULL, `client_tga_hash` TEXT NOT NULL, `small_capsule` TEXT NOT NULL, `header_image` TEXT NOT NULL, `library_assets` TEXT NOT NULL, `primary_genre` INTEGER NOT NULL, `review_score` INTEGER NOT NULL, `review_percentage` INTEGER NOT NULL, `controller_support` INTEGER NOT NULL, `demo_of_app_id` INTEGER NOT NULL, `developer` TEXT NOT NULL, `publisher` TEXT NOT NULL, `homepage_url` TEXT NOT NULL, `game_manual_url` TEXT NOT NULL, `load_all_before_launch` INTEGER NOT NULL, `dlc_app_ids` TEXT NOT NULL, `is_free_app` INTEGER NOT NULL, `dlc_for_app_id` INTEGER NOT NULL, `must_own_app_to_purchase` INTEGER NOT NULL, `dlc_available_on_store` INTEGER NOT NULL, `optional_dlc` INTEGER NOT NULL, `game_dir` TEXT NOT NULL, `install_script` TEXT NOT NULL, `no_servers` INTEGER NOT NULL, `order` INTEGER NOT NULL, `primary_cache` INTEGER NOT NULL, `valid_os_list` INTEGER NOT NULL, `third_party_cd_key` INTEGER NOT NULL, `visible_only_when_installed` INTEGER NOT NULL, `visible_only_when_subscribed` INTEGER NOT NULL, `launch_eula_url` TEXT NOT NULL, `require_default_install_folder` INTEGER NOT NULL, `content_type` INTEGER NOT NULL, `install_dir` TEXT NOT NULL, `use_launch_cmd_line` INTEGER NOT NULL, `launch_without_workshop_updates` INTEGER NOT NULL, `use_mms` INTEGER NOT NULL, `install_script_signature` TEXT NOT NULL, `install_script_override` INTEGER NOT NULL, `config` TEXT NOT NULL, `ufs` TEXT NOT NULL, `workshop_mods` INTEGER NOT NULL DEFAULT 0, `enabled_workshop_item_ids` TEXT NOT NULL DEFAULT '', `workshop_download_pending` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "packageId",
+ "columnName": "package_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "ownerAccountId",
+ "columnName": "owner_account_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "licenseFlags",
+ "columnName": "license_flags",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "receivedPICS",
+ "columnName": "received_pics",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastChangeNumber",
+ "columnName": "last_change_number",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "ufsParseVersion",
+ "columnName": "ufs_parse_version",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "depots",
+ "columnName": "depots",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "branches",
+ "columnName": "branches",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "type",
+ "columnName": "type",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "osList",
+ "columnName": "os_list",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "releaseState",
+ "columnName": "release_state",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "releaseDate",
+ "columnName": "release_date",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "metacriticScore",
+ "columnName": "metacritic_score",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "metacriticFullUrl",
+ "columnName": "metacritic_full_url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "logoHash",
+ "columnName": "logo_hash",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "logoSmallHash",
+ "columnName": "logo_small_hash",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "iconHash",
+ "columnName": "icon_hash",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "clientIconHash",
+ "columnName": "client_icon_hash",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "clientTgaHash",
+ "columnName": "client_tga_hash",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "smallCapsule",
+ "columnName": "small_capsule",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "headerImage",
+ "columnName": "header_image",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "libraryAssets",
+ "columnName": "library_assets",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "primaryGenre",
+ "columnName": "primary_genre",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "reviewScore",
+ "columnName": "review_score",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "reviewPercentage",
+ "columnName": "review_percentage",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "controllerSupport",
+ "columnName": "controller_support",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "demoOfAppId",
+ "columnName": "demo_of_app_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "developer",
+ "columnName": "developer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "publisher",
+ "columnName": "publisher",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "homepageUrl",
+ "columnName": "homepage_url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "gameManualUrl",
+ "columnName": "game_manual_url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "loadAllBeforeLaunch",
+ "columnName": "load_all_before_launch",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dlcAppIds",
+ "columnName": "dlc_app_ids",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isFreeApp",
+ "columnName": "is_free_app",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dlcForAppId",
+ "columnName": "dlc_for_app_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mustOwnAppToPurchase",
+ "columnName": "must_own_app_to_purchase",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dlcAvailableOnStore",
+ "columnName": "dlc_available_on_store",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "optionalDlc",
+ "columnName": "optional_dlc",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "gameDir",
+ "columnName": "game_dir",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "installScript",
+ "columnName": "install_script",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "noServers",
+ "columnName": "no_servers",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "order",
+ "columnName": "order",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "primaryCache",
+ "columnName": "primary_cache",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "validOSList",
+ "columnName": "valid_os_list",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "thirdPartyCdKey",
+ "columnName": "third_party_cd_key",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "visibleOnlyWhenInstalled",
+ "columnName": "visible_only_when_installed",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "visibleOnlyWhenSubscribed",
+ "columnName": "visible_only_when_subscribed",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "launchEulaUrl",
+ "columnName": "launch_eula_url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "requireDefaultInstallFolder",
+ "columnName": "require_default_install_folder",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "contentType",
+ "columnName": "content_type",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "installDir",
+ "columnName": "install_dir",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "useLaunchCmdLine",
+ "columnName": "use_launch_cmd_line",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "launchWithoutWorkshopUpdates",
+ "columnName": "launch_without_workshop_updates",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "useMms",
+ "columnName": "use_mms",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "installScriptSignature",
+ "columnName": "install_script_signature",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "installScriptOverride",
+ "columnName": "install_script_override",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "config",
+ "columnName": "config",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "ufs",
+ "columnName": "ufs",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "workshopMods",
+ "columnName": "workshop_mods",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "enabledWorkshopItemIds",
+ "columnName": "enabled_workshop_item_ids",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "workshopDownloadPending",
+ "columnName": "workshop_download_pending",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "steam_license",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageId` INTEGER NOT NULL, `last_change_number` INTEGER NOT NULL, `time_created` INTEGER NOT NULL, `time_next_process` INTEGER NOT NULL, `minute_limit` INTEGER NOT NULL, `minutes_used` INTEGER NOT NULL, `payment_method` INTEGER NOT NULL, `license_flags` INTEGER NOT NULL, `purchase_code` TEXT NOT NULL, `license_type` INTEGER NOT NULL, `territory_code` INTEGER NOT NULL, `access_token` INTEGER NOT NULL, `owner_account_id` TEXT NOT NULL, `master_package_id` INTEGER NOT NULL, `app_ids` TEXT NOT NULL, `depot_ids` TEXT NOT NULL, PRIMARY KEY(`packageId`))",
+ "fields": [
+ {
+ "fieldPath": "packageId",
+ "columnName": "packageId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastChangeNumber",
+ "columnName": "last_change_number",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timeCreated",
+ "columnName": "time_created",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timeNextProcess",
+ "columnName": "time_next_process",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "minuteLimit",
+ "columnName": "minute_limit",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "minutesUsed",
+ "columnName": "minutes_used",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "paymentMethod",
+ "columnName": "payment_method",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "licenseFlags",
+ "columnName": "license_flags",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "purchaseCode",
+ "columnName": "purchase_code",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "licenseType",
+ "columnName": "license_type",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "territoryCode",
+ "columnName": "territory_code",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "accessToken",
+ "columnName": "access_token",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "ownerAccountId",
+ "columnName": "owner_account_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "masterPackageID",
+ "columnName": "master_package_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "appIds",
+ "columnName": "app_ids",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "depotIds",
+ "columnName": "depot_ids",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "packageId"
+ ]
+ }
+ },
+ {
+ "tableName": "gog_games",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `slug` TEXT NOT NULL, `download_size` INTEGER NOT NULL, `install_size` INTEGER NOT NULL, `is_installed` INTEGER NOT NULL, `install_path` TEXT NOT NULL, `image_url` TEXT NOT NULL, `icon_url` TEXT NOT NULL, `background_url` TEXT NOT NULL DEFAULT '', `description` TEXT NOT NULL, `release_date` TEXT NOT NULL, `developer` TEXT NOT NULL, `publisher` TEXT NOT NULL, `genres` TEXT NOT NULL, `languages` TEXT NOT NULL, `last_played` INTEGER NOT NULL, `play_time` INTEGER NOT NULL, `type` INTEGER NOT NULL, `exclude` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "slug",
+ "columnName": "slug",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "downloadSize",
+ "columnName": "download_size",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "installSize",
+ "columnName": "install_size",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isInstalled",
+ "columnName": "is_installed",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "installPath",
+ "columnName": "install_path",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "imageUrl",
+ "columnName": "image_url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "iconUrl",
+ "columnName": "icon_url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "backgroundUrl",
+ "columnName": "background_url",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "description",
+ "columnName": "description",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "releaseDate",
+ "columnName": "release_date",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "developer",
+ "columnName": "developer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "publisher",
+ "columnName": "publisher",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "genres",
+ "columnName": "genres",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "languages",
+ "columnName": "languages",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastPlayed",
+ "columnName": "last_played",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "playTime",
+ "columnName": "play_time",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "type",
+ "columnName": "type",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "exclude",
+ "columnName": "exclude",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "epic_games",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `catalog_id` TEXT NOT NULL, `app_name` TEXT NOT NULL, `title` TEXT NOT NULL, `namespace` TEXT NOT NULL, `developer` TEXT NOT NULL, `publisher` TEXT NOT NULL, `is_installed` INTEGER NOT NULL, `install_path` TEXT NOT NULL, `platform` TEXT NOT NULL, `version` TEXT NOT NULL, `executable` TEXT NOT NULL, `install_size` INTEGER NOT NULL, `download_size` INTEGER NOT NULL, `art_cover` TEXT NOT NULL, `art_square` TEXT NOT NULL, `art_logo` TEXT NOT NULL, `art_portrait` TEXT NOT NULL, `can_run_offline` INTEGER NOT NULL, `requires_ot` INTEGER NOT NULL, `cloud_save_enabled` INTEGER NOT NULL, `save_folder` TEXT NOT NULL, `third_party_managed_app` TEXT NOT NULL, `is_ea_managed` INTEGER NOT NULL, `is_dlc` INTEGER NOT NULL, `base_game_app_name` TEXT NOT NULL, `description` TEXT NOT NULL, `release_date` TEXT NOT NULL, `genres` TEXT NOT NULL, `tags` TEXT NOT NULL, `last_played` INTEGER NOT NULL, `play_time` INTEGER NOT NULL, `type` INTEGER NOT NULL, `eos_catalog_item_id` TEXT NOT NULL, `eos_app_id` TEXT NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "catalogId",
+ "columnName": "catalog_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "appName",
+ "columnName": "app_name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "namespace",
+ "columnName": "namespace",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "developer",
+ "columnName": "developer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "publisher",
+ "columnName": "publisher",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isInstalled",
+ "columnName": "is_installed",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "installPath",
+ "columnName": "install_path",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "platform",
+ "columnName": "platform",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "version",
+ "columnName": "version",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "executable",
+ "columnName": "executable",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "installSize",
+ "columnName": "install_size",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "downloadSize",
+ "columnName": "download_size",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artCover",
+ "columnName": "art_cover",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artSquare",
+ "columnName": "art_square",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artLogo",
+ "columnName": "art_logo",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artPortrait",
+ "columnName": "art_portrait",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "canRunOffline",
+ "columnName": "can_run_offline",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "requiresOT",
+ "columnName": "requires_ot",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "cloudSaveEnabled",
+ "columnName": "cloud_save_enabled",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "saveFolder",
+ "columnName": "save_folder",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "thirdPartyManagedApp",
+ "columnName": "third_party_managed_app",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isEAManaged",
+ "columnName": "is_ea_managed",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isDLC",
+ "columnName": "is_dlc",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "baseGameAppName",
+ "columnName": "base_game_app_name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "description",
+ "columnName": "description",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "releaseDate",
+ "columnName": "release_date",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "genres",
+ "columnName": "genres",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "tags",
+ "columnName": "tags",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastPlayed",
+ "columnName": "last_played",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "playTime",
+ "columnName": "play_time",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "type",
+ "columnName": "type",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "eosCatalogItemId",
+ "columnName": "eos_catalog_item_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "eosAppId",
+ "columnName": "eos_app_id",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "amazon_games",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`app_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `product_id` TEXT NOT NULL, `entitlement_id` TEXT NOT NULL DEFAULT '', `title` TEXT NOT NULL, `is_installed` INTEGER NOT NULL, `install_path` TEXT NOT NULL, `art_url` TEXT NOT NULL, `hero_url` TEXT NOT NULL DEFAULT '', `purchased_date` TEXT NOT NULL, `developer` TEXT NOT NULL DEFAULT '', `publisher` TEXT NOT NULL DEFAULT '', `release_date` TEXT NOT NULL DEFAULT '', `download_size` INTEGER NOT NULL DEFAULT 0, `install_size` INTEGER NOT NULL DEFAULT 0, `version_id` TEXT NOT NULL DEFAULT '', `product_sku` TEXT NOT NULL DEFAULT '', `last_played` INTEGER NOT NULL DEFAULT 0, `play_time_minutes` INTEGER NOT NULL DEFAULT 0, `product_json` TEXT NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "appId",
+ "columnName": "app_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "productId",
+ "columnName": "product_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "entitlementId",
+ "columnName": "entitlement_id",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isInstalled",
+ "columnName": "is_installed",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "installPath",
+ "columnName": "install_path",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artUrl",
+ "columnName": "art_url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "heroUrl",
+ "columnName": "hero_url",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "purchasedDate",
+ "columnName": "purchased_date",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "developer",
+ "columnName": "developer",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "publisher",
+ "columnName": "publisher",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "releaseDate",
+ "columnName": "release_date",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "downloadSize",
+ "columnName": "download_size",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "installSize",
+ "columnName": "install_size",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "versionId",
+ "columnName": "version_id",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "productSku",
+ "columnName": "product_sku",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "lastPlayed",
+ "columnName": "last_played",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "playTimeMinutes",
+ "columnName": "play_time_minutes",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "productJson",
+ "columnName": "product_json",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "app_id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_amazon_games_product_id",
+ "unique": false,
+ "columnNames": [
+ "product_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_amazon_games_product_id` ON `${TABLE_NAME}` (`product_id`)"
+ }
+ ]
+ },
+ {
+ "tableName": "downloading_app_info",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`appId` INTEGER NOT NULL, `dlcAppIds` TEXT NOT NULL, `branch` TEXT NOT NULL DEFAULT 'public', PRIMARY KEY(`appId`))",
+ "fields": [
+ {
+ "fieldPath": "appId",
+ "columnName": "appId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dlcAppIds",
+ "columnName": "dlcAppIds",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "branch",
+ "columnName": "branch",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'public'"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "appId"
+ ]
+ }
+ },
+ {
+ "tableName": "steam_unlocked_branch",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`appId` INTEGER NOT NULL, `branchName` TEXT NOT NULL, `password` TEXT NOT NULL, PRIMARY KEY(`appId`, `branchName`))",
+ "fields": [
+ {
+ "fieldPath": "appId",
+ "columnName": "appId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "branchName",
+ "columnName": "branchName",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "password",
+ "columnName": "password",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "appId",
+ "branchName"
+ ]
+ }
+ }
+ ],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c5f7f4b4360bc641147c4e3772e07792')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/assets/wincomponents/README_openal.md b/app/src/main/assets/wincomponents/README_openal.md
new file mode 100644
index 0000000000..5e72784f3b
--- /dev/null
+++ b/app/src/main/assets/wincomponents/README_openal.md
@@ -0,0 +1,73 @@
+# OpenAL Soft — Audio DLL Override
+
+The `openal.tzst` archive contains OpenAL Soft v1.25.1 DLLs that provide
+OpenAL audio support for games like Mirror's Edge, some Unreal Engine 3
+titles, and GOG releases that rely on OpenAL.
+
+## How it works
+
+OpenAL is **not** part of the Win Components system (to avoid breaking
+config imports). Instead, the user adds the standard `WINEDLLOVERRIDES`
+environment variable via the container's Environment tab.
+
+### Common presets (available in the dropdown)
+
+| WINEDLLOVERRIDES value | Effect |
+|-------------------------------------------|------------------------------------------------------------|
+| `openal32=native,builtin` | Extracts DLLs, Wine prefers native openal32 |
+| `soft_oal=native` | Extracts DLLs, Wine uses native soft_oal |
+| `openal32=native,builtin;soft_oal=native` | Extracts DLLs, both overrides at once |
+| *(not set)* | No override — Wine uses its own builtin (default) |
+
+Users can also type any custom `WINEDLLOVERRIDES` value in the create dialog.
+
+At boot, the app:
+1. Reads `WINEDLLOVERRIDES` from the container's env vars
+2. If the value mentions `openal32` or `soft_oal`, extracts `openal.tzst` into `drive_c/windows/`
+3. Passes `WINEDLLOVERRIDES` directly to Wine — no translation needed
+
+### Recommended setting
+
+Use `openal32=native,builtin` for most games. This places `openal32.dll` (the standard
+OpenAL system name) and `soft_oal.dll` (the redistributable name) in both
+`system32/` and `syswow64/`, and tells Wine to prefer the native DLL.
+
+## Archive contents
+
+```
+./system32/openal32.dll ← 64-bit OpenAL Soft (as system openal32)
+./system32/soft_oal.dll ← 64-bit OpenAL Soft (original redistributable name)
+./syswow64/openal32.dll ← 32-bit OpenAL Soft (as system openal32)
+./syswow64/soft_oal.dll ← 32-bit OpenAL Soft (original redistributable name)
+```
+
+Both names coexist: most games load `openal32.dll` via the standard API,
+but some titles (GOG releases, older UE3 games) load `soft_oal.dll` directly.
+
+## How to rebuild openal.tzst
+
+1. Download OpenAL Soft from https://openal-soft.org/ (latest release)
+ - You need the **bin** zip with both Win32 and Win64 `soft_oal.dll`
+
+2. Create a staging directory:
+ ```
+ system32/openal32.dll (Win64 soft_oal.dll copied+renamed)
+ system32/soft_oal.dll (Win64 soft_oal.dll original name)
+ syswow64/openal32.dll (Win32 soft_oal.dll copied+renamed)
+ syswow64/soft_oal.dll (Win32 soft_oal.dll original name)
+ ```
+
+3. Create the archive:
+ ```bash
+ cd staging_dir
+ tar -cf openal.tar ./system32 ./syswow64
+ zstd openal.tar -o openal.tzst
+ ```
+
+4. Place `openal.tzst` in `app/src/main/assets/wincomponents/openal.tzst`
+
+## Note about the pre-install step
+
+The existing `OpenALStep` pre-install step searches for game-bundled OpenAL
+installers (oalinst.exe). That step still runs regardless of this override.
+This provides a system-level fallback for games that don't bundle their own.
diff --git a/app/src/main/assets/wincomponents/openal.tzst b/app/src/main/assets/wincomponents/openal.tzst
new file mode 100644
index 0000000000..98a96c2afa
Binary files /dev/null and b/app/src/main/assets/wincomponents/openal.tzst differ
diff --git a/app/src/main/java/app/gamenative/data/AppInfo.kt b/app/src/main/java/app/gamenative/data/AppInfo.kt
index 1c58417a83..4876c8a5be 100644
--- a/app/src/main/java/app/gamenative/data/AppInfo.kt
+++ b/app/src/main/java/app/gamenative/data/AppInfo.kt
@@ -15,4 +15,6 @@ data class AppInfo (
val dlcDepots: List = emptyList(),
@ColumnInfo("branch", defaultValue = "public")
val branch: String = "public",
+ @ColumnInfo(name = "recovered_install_size_bytes", defaultValue = "0")
+ val recoveredInstallSizeBytes: Long = 0L,
)
diff --git a/app/src/main/java/app/gamenative/data/DepotInfo.kt b/app/src/main/java/app/gamenative/data/DepotInfo.kt
index 09ba070502..380659e911 100644
--- a/app/src/main/java/app/gamenative/data/DepotInfo.kt
+++ b/app/src/main/java/app/gamenative/data/DepotInfo.kt
@@ -21,6 +21,7 @@ data class DepotInfo(
val encryptedManifests: Map,
val language: String = "",
val realm: String = "",
+ val systemDefined: Boolean = false,
val steamDeck: Boolean = false,
) {
/** Windows or OS-untagged (neither Linux nor macOS) */
diff --git a/app/src/main/java/app/gamenative/db/PluviaDatabase.kt b/app/src/main/java/app/gamenative/db/PluviaDatabase.kt
index f61f6b1eaa..38ba38d5a8 100644
--- a/app/src/main/java/app/gamenative/db/PluviaDatabase.kt
+++ b/app/src/main/java/app/gamenative/db/PluviaDatabase.kt
@@ -53,7 +53,7 @@ const val DATABASE_NAME = "pluvia.db"
DownloadingAppInfo::class,
SteamUnlockedBranch::class,
],
- version = 18,
+ version = 19,
// For db migration, visit https://developer.android.com/training/data-storage/room/migrating-db-versions for more information
exportSchema = true, // It is better to handle db changes carefully, as GN is getting much more users.
autoMigrations = [
@@ -71,6 +71,7 @@ const val DATABASE_NAME = "pluvia.db"
// duplicate column name: ufs_parse_version (code 1 SQLITE_ERROR)
// v16 users will fallback to destructive migration (only cached Steam data, re-fetched on login)
AutoMigration(from = 17, to = 18), // Added workshop_mods, enabled_workshop_item_ids, workshop_download_pending to steam_app
+ AutoMigration(from = 18, to = 19), // Added recovered_install_size_bytes to app_info
]
)
@TypeConverters(
diff --git a/app/src/main/java/app/gamenative/db/dao/AppInfoDao.kt b/app/src/main/java/app/gamenative/db/dao/AppInfoDao.kt
index 306fcfcb9c..52722dd694 100644
--- a/app/src/main/java/app/gamenative/db/dao/AppInfoDao.kt
+++ b/app/src/main/java/app/gamenative/db/dao/AppInfoDao.kt
@@ -23,6 +23,9 @@ interface AppInfoDao {
@Query("SELECT * FROM app_info WHERE id = :appId")
suspend fun getInstalledApp(appId: Int): AppInfo?
+ @Query("SELECT * FROM app_info")
+ suspend fun getAll(): List
+
@Query("SELECT * FROM app_info WHERE id = :appId")
suspend fun get(appId: Int): AppInfo?
diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt
index 0ded1d459d..8f7174a27f 100644
--- a/app/src/main/java/app/gamenative/service/SteamService.kt
+++ b/app/src/main/java/app/gamenative/service/SteamService.kt
@@ -760,8 +760,8 @@ class SteamService : Service(), IChallengeUrlChanged {
if (depot.language.isNotEmpty() && depot.language != preferredLanguage)
return false
// 6. Package grants this depot — prevents grabbing region depots the user has no license for.
- // Skip for DLC depots: they're licensed via their own package, already validated by check 4.
- if (depot.dlcAppId == INVALID_APP_ID && licensedDepotIds != null && depot.depotId !in licensedDepotIds)
+ // Skip for DLC and systemDefined depots: DLC licensed via own package (check 4), systemDefined always granted.
+ if (depot.dlcAppId == INVALID_APP_ID && !depot.systemDefined && licensedDepotIds != null && depot.depotId !in licensedDepotIds)
return false
// 7. Prefer non-Steam-Deck depot when both exist (we're on Android, not Deck)
if (depot.steamDeck && preferNonDeckWindows)
@@ -860,6 +860,7 @@ class SteamService : Service(), IChallengeUrlChanged {
language = depot.language,
manifests = depot.manifests,
encryptedManifests = depot.encryptedManifests,
+ systemDefined = depot.systemDefined,
steamDeck = depot.steamDeck,
)
}
diff --git a/app/src/main/java/app/gamenative/service/gog/GOGDownloadManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGDownloadManager.kt
index 50cf7862b8..161661e1bf 100644
--- a/app/src/main/java/app/gamenative/service/gog/GOGDownloadManager.kt
+++ b/app/src/main/java/app/gamenative/service/gog/GOGDownloadManager.kt
@@ -18,6 +18,7 @@ import java.io.ByteArrayOutputStream
import java.io.BufferedOutputStream
import java.io.File
import java.io.FileOutputStream
+import java.nio.file.Files
import java.security.DigestOutputStream
import java.security.MessageDigest
import java.util.zip.Inflater
@@ -260,6 +261,21 @@ class GOGDownloadManager @Inject constructor(
}
Timber.tag("GOG").d("Skipping ${beforeCount - gameFiles.size} existing file(s), downloading ${gameFiles.size}")
+ val beforeSupportCount = supportFiles.size
+ supportFiles = supportFiles.filter { file ->
+ val installRelativePath = getSupportInstallPath(file.path)
+ val outputFile = File(gameInstallDir, installRelativePath)
+ val expectedSize = file.chunks.sumOf { it.size }
+ !fileExistsWithCorrectSize(outputFile, expectedSize, file.md5)
+ }
+ val supportFilesForGameDirAssemble = supportFiles.map { file ->
+ val installRelativePath = getSupportInstallPath(file.path)
+ if (installRelativePath != file.path) file.copy(path = installRelativePath) else file
+ }
+ Timber.tag("GOG").d(
+ "Skipping ${beforeSupportCount - supportFiles.size} existing support file(s), downloading ${supportFiles.size}",
+ )
+
// Calculate sizes separately for transparency
val (baseGameFiles, _) = parser.separateSupportFiles(baseFiles)
val baseGameSize = parser.calculateTotalSize(baseGameFiles)
@@ -284,15 +300,16 @@ class GOGDownloadManager @Inject constructor(
)
// Step 6: Calculate sizes and extract chunk hashes
- val totalSize = parser.calculateTotalSize(gameFiles)
- val chunkHashes = parser.extractChunkHashes(gameFiles)
+ val allDownloadFiles = gameFiles + supportFiles
+ val totalSize = parser.calculateTotalSize(allDownloadFiles)
+ val chunkHashes = parser.extractChunkHashes(allDownloadFiles)
Timber.tag("GOG").d(
"""
|Download stats:
| Total compressed size: ${totalSize / 1_000_000.0} MB (${if (withDlcs) "including DLC" else "base game only"})
| Unique chunks: ${chunkHashes.size}
- | Files: ${gameFiles.size}
+ | Files: ${allDownloadFiles.size}
""".trimMargin(),
)
@@ -307,7 +324,7 @@ class GOGDownloadManager @Inject constructor(
Timber.tag("GOG").d("Mapping chunks to products. gameId parameter: $gameId, realGameId: $realGameId, manifest baseProductId: ${gameManifest.baseProductId}")
- val filesToDownloadPaths = gameFiles.map { it.path }.toSet()
+ val filesToDownloadPaths = allDownloadFiles.map { it.path }.toSet()
// Map each chunk to its product ID using depot info
allFilesWithDepots.forEach { (file, depotProductId) ->
if (file.path !in filesToDownloadPaths) return@forEach
@@ -404,7 +421,8 @@ class GOGDownloadManager @Inject constructor(
// Use installPath directly since it already includes the game-specific folder
gameInstallDir.mkdirs()
- val assembleResult = assembleFiles(gameFiles, chunkCacheDir, gameInstallDir, downloadInfo)
+ val filesToAssembleInGameDir = gameFiles + supportFilesForGameDirAssemble
+ val assembleResult = assembleFiles(filesToAssembleInGameDir, chunkCacheDir, gameInstallDir, downloadInfo)
if (assembleResult.isFailure) {
MarkerUtils.removeMarker(installPath.absolutePath, Marker.DOWNLOAD_IN_PROGRESS_MARKER)
return@withContext assembleResult
@@ -1428,6 +1446,10 @@ class GOGDownloadManager @Inject constructor(
return digest.digest().joinToString("") { "%02x".format(it) }
}
+ private fun getSupportInstallPath(path: String): String {
+ return if (path.startsWith("app/")) path.removePrefix("app/") else path
+ }
+
/**
* Check if file exists and has the expected size. When [expectedMd5] is non-null/non-blank,
* also verifies content MD5 to reject corrupted files; short-circuits on size mismatch before hashing.
@@ -1457,10 +1479,8 @@ class GOGDownloadManager @Inject constructor(
}
/**
- * Calculate the total size of a directory recursively
- *
- * @param directory The directory to calculate size for
- * @return Total size in bytes
+ * Total size under [directory]. Symlinks are skipped so GOG scriptinterpreter
+ * `rootdir` (symlink to install root) does not re-count the whole tree.
*/
private fun calculateDirectorySize(directory: File): Long {
var size = 0L
@@ -1471,6 +1491,9 @@ class GOGDownloadManager @Inject constructor(
val files = directory.listFiles() ?: return 0L
for (file in files) {
+ if (Files.isSymbolicLink(file.toPath())) {
+ continue
+ }
size += if (file.isDirectory) {
calculateDirectorySize(file)
} else {
diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/EnvironmentTab.kt b/app/src/main/java/app/gamenative/ui/component/dialog/EnvironmentTab.kt
index f042401221..00f8806128 100644
--- a/app/src/main/java/app/gamenative/ui/component/dialog/EnvironmentTab.kt
+++ b/app/src/main/java/app/gamenative/ui/component/dialog/EnvironmentTab.kt
@@ -9,6 +9,8 @@ import androidx.compose.material.icons.automirrored.outlined.ViewList
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.outlined.AddCircleOutline
import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
@@ -153,10 +155,36 @@ fun EnvironmentTabContent(state: ContainerConfigState) {
colors = settingsTileColors(),
)
} else {
+ var suggestionsExpanded by remember { mutableStateOf(false) }
+ val hasSuggestions = selectedEnvVarInfo?.selectionType == EnvVarSelectionType.SUGGESTIONS
OutlinedTextField(
value = envVarValue,
onValueChange = { envVarValue = it },
label = { Text(text = stringResource(R.string.value)) },
+ trailingIcon = if (hasSuggestions) {
+ {
+ IconButton(onClick = { suggestionsExpanded = true }) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Outlined.ViewList,
+ contentDescription = "Presets",
+ )
+ }
+ DropdownMenu(
+ expanded = suggestionsExpanded,
+ onDismissRequest = { suggestionsExpanded = false },
+ ) {
+ selectedEnvVarInfo!!.possibleValues.forEach { suggestion ->
+ DropdownMenuItem(
+ text = { Text(suggestion) },
+ onClick = {
+ envVarValue = suggestion
+ suggestionsExpanded = false
+ },
+ )
+ }
+ }
+ }
+ } else null,
)
}
}
diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/GeneralTab.kt b/app/src/main/java/app/gamenative/ui/component/dialog/GeneralTab.kt
index 6a938b2e06..0fdf1a5cdc 100644
--- a/app/src/main/java/app/gamenative/ui/component/dialog/GeneralTab.kt
+++ b/app/src/main/java/app/gamenative/ui/component/dialog/GeneralTab.kt
@@ -320,13 +320,13 @@ fun GeneralTabContent(
state = config.forceDlc,
onCheckedChange = { state.config.value = config.copy(forceDlc = it) },
)
- SettingsSwitch(
- colors = settingsTileColorsAlt(),
- title = { Text(text = stringResource(R.string.local_saves_only)) },
- subtitle = { Text(text = stringResource(R.string.local_saves_only_description)) },
- state = config.localSavesOnly,
- onCheckedChange = { state.config.value = config.copy(localSavesOnly = it) },
- )
+// SettingsSwitch(
+// colors = settingsTileColorsAlt(),
+// title = { Text(text = stringResource(R.string.local_saves_only)) },
+// subtitle = { Text(text = stringResource(R.string.local_saves_only_description)) },
+// state = config.localSavesOnly,
+// onCheckedChange = { state.config.value = config.copy(localSavesOnly = it) },
+// )
SettingsSwitch(
colors = settingsTileColorsAlt(),
title = { Text(text = stringResource(R.string.use_legacy_drm)) },
diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/GraphicsTab.kt b/app/src/main/java/app/gamenative/ui/component/dialog/GraphicsTab.kt
index 43cc5a7e6b..614bef6216 100644
--- a/app/src/main/java/app/gamenative/ui/component/dialog/GraphicsTab.kt
+++ b/app/src/main/java/app/gamenative/ui/component/dialog/GraphicsTab.kt
@@ -119,97 +119,95 @@ fun GraphicsTabContent(state: ContainerConfigState) {
state.config.value = config.copy(graphicsDriverConfig = cfg.toString())
},
)
- if (config.wineVersion.contains("arm64ec", true)) {
- SettingsListDropdown(
- colors = settingsTileColors(),
- title = { Text(text = stringResource(R.string.present_modes)) },
- value = state.presentModeIndex.value.coerceIn(0, state.presentModes.lastIndex.coerceAtLeast(0)),
- items = state.presentModes,
- onItemSelected = { idx ->
- state.presentModeIndex.value = idx
- val cfg = KeyValueSet(config.graphicsDriverConfig)
- cfg.put("presentMode", state.presentModes[idx])
- state.config.value = config.copy(graphicsDriverConfig = cfg.toString())
- },
- )
- SettingsListDropdown(
- colors = settingsTileColors(),
- title = { Text(text = stringResource(R.string.resource_type)) },
- value = state.resourceTypeIndex.value.coerceIn(0, state.resourceTypes.lastIndex.coerceAtLeast(0)),
- items = state.resourceTypes,
- onItemSelected = { idx ->
- state.resourceTypeIndex.value = idx
- val cfg = KeyValueSet(config.graphicsDriverConfig)
- cfg.put("resourceType", state.resourceTypes[idx])
- state.config.value = config.copy(graphicsDriverConfig = cfg.toString())
- },
- )
- SettingsListDropdown(
- colors = settingsTileColors(),
- title = { Text(text = stringResource(R.string.bcn_emulation)) },
- value = state.bcnEmulationIndex.value.coerceIn(0, state.bcnEmulationEntries.lastIndex.coerceAtLeast(0)),
- items = state.bcnEmulationEntries,
- onItemSelected = { idx ->
- state.bcnEmulationIndex.value = idx
- val cfg = KeyValueSet(config.graphicsDriverConfig)
- cfg.put("bcnEmulation", state.bcnEmulationEntries[idx])
- state.config.value = config.copy(graphicsDriverConfig = cfg.toString())
- },
- )
- SettingsListDropdown(
- colors = settingsTileColors(),
- title = { Text(text = stringResource(R.string.bcn_emulation_type)) },
- value = state.bcnEmulationTypeIndex.value.coerceIn(0, state.bcnEmulationTypeEntries.lastIndex.coerceAtLeast(0)),
- items = state.bcnEmulationTypeEntries,
- onItemSelected = { i ->
- state.bcnEmulationTypeIndex.value = i
- val cfg = KeyValueSet(config.graphicsDriverConfig)
- cfg.put("bcnEmulationType", state.bcnEmulationTypeEntries[i])
- state.config.value = config.copy(graphicsDriverConfig = cfg.toString())
- },
- )
- // Sharpness (vkBasalt)
- SettingsListDropdown(
- colors = settingsTileColors(),
- title = { Text(text = stringResource(R.string.sharpness_effect)) },
- value = state.sharpnessEffectIndex.value.coerceIn(0, state.sharpnessEffects.lastIndex.coerceAtLeast(0)),
- items = state.sharpnessDisplayItems,
- onItemSelected = { idx ->
- state.sharpnessEffectIndex.value = idx
- state.config.value = config.copy(sharpnessEffect = state.sharpnessEffects[idx])
- },
- )
- val selectedBoost = state.sharpnessEffects
- .getOrNull(state.sharpnessEffectIndex.value)
- ?.equals("None", ignoreCase = true)
- ?.not() ?: false
- if (selectedBoost) {
- Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
- Text(text = stringResource(R.string.sharpness_level))
- Slider(
- value = state.sharpnessLevel.value.toFloat(),
- onValueChange = { newValue ->
- val clamped = newValue.roundToInt().coerceIn(0, 100)
- state.sharpnessLevel.value = clamped
- state.config.value = config.copy(sharpnessLevel = clamped)
- },
- valueRange = 0f..100f,
- )
- Text(text = "${state.sharpnessLevel.value}%")
- }
- Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
- Text(text = stringResource(R.string.sharpness_denoise))
- Slider(
- value = state.sharpnessDenoise.value.toFloat(),
- onValueChange = { newValue ->
- val clamped = newValue.roundToInt().coerceIn(0, 100)
- state.sharpnessDenoise.value = clamped
- state.config.value = config.copy(sharpnessDenoise = clamped)
- },
- valueRange = 0f..100f,
- )
- Text(text = "${state.sharpnessDenoise.value}%")
- }
+ SettingsListDropdown(
+ colors = settingsTileColors(),
+ title = { Text(text = stringResource(R.string.present_modes)) },
+ value = state.presentModeIndex.value.coerceIn(0, state.presentModes.lastIndex.coerceAtLeast(0)),
+ items = state.presentModes,
+ onItemSelected = { idx ->
+ state.presentModeIndex.value = idx
+ val cfg = KeyValueSet(config.graphicsDriverConfig)
+ cfg.put("presentMode", state.presentModes[idx])
+ state.config.value = config.copy(graphicsDriverConfig = cfg.toString())
+ },
+ )
+ SettingsListDropdown(
+ colors = settingsTileColors(),
+ title = { Text(text = stringResource(R.string.resource_type)) },
+ value = state.resourceTypeIndex.value.coerceIn(0, state.resourceTypes.lastIndex.coerceAtLeast(0)),
+ items = state.resourceTypes,
+ onItemSelected = { idx ->
+ state.resourceTypeIndex.value = idx
+ val cfg = KeyValueSet(config.graphicsDriverConfig)
+ cfg.put("resourceType", state.resourceTypes[idx])
+ state.config.value = config.copy(graphicsDriverConfig = cfg.toString())
+ },
+ )
+ SettingsListDropdown(
+ colors = settingsTileColors(),
+ title = { Text(text = stringResource(R.string.bcn_emulation)) },
+ value = state.bcnEmulationIndex.value.coerceIn(0, state.bcnEmulationEntries.lastIndex.coerceAtLeast(0)),
+ items = state.bcnEmulationEntries,
+ onItemSelected = { idx ->
+ state.bcnEmulationIndex.value = idx
+ val cfg = KeyValueSet(config.graphicsDriverConfig)
+ cfg.put("bcnEmulation", state.bcnEmulationEntries[idx])
+ state.config.value = config.copy(graphicsDriverConfig = cfg.toString())
+ },
+ )
+ SettingsListDropdown(
+ colors = settingsTileColors(),
+ title = { Text(text = stringResource(R.string.bcn_emulation_type)) },
+ value = state.bcnEmulationTypeIndex.value.coerceIn(0, state.bcnEmulationTypeEntries.lastIndex.coerceAtLeast(0)),
+ items = state.bcnEmulationTypeEntries,
+ onItemSelected = { i ->
+ state.bcnEmulationTypeIndex.value = i
+ val cfg = KeyValueSet(config.graphicsDriverConfig)
+ cfg.put("bcnEmulationType", state.bcnEmulationTypeEntries[i])
+ state.config.value = config.copy(graphicsDriverConfig = cfg.toString())
+ },
+ )
+ // Sharpness (vkBasalt)
+ SettingsListDropdown(
+ colors = settingsTileColors(),
+ title = { Text(text = stringResource(R.string.sharpness_effect)) },
+ value = state.sharpnessEffectIndex.value.coerceIn(0, state.sharpnessEffects.lastIndex.coerceAtLeast(0)),
+ items = state.sharpnessDisplayItems,
+ onItemSelected = { idx ->
+ state.sharpnessEffectIndex.value = idx
+ state.config.value = config.copy(sharpnessEffect = state.sharpnessEffects[idx])
+ },
+ )
+ val selectedBoost = state.sharpnessEffects
+ .getOrNull(state.sharpnessEffectIndex.value)
+ ?.equals("None", ignoreCase = true)
+ ?.not() ?: false
+ if (selectedBoost) {
+ Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
+ Text(text = stringResource(R.string.sharpness_level))
+ Slider(
+ value = state.sharpnessLevel.value.toFloat(),
+ onValueChange = { newValue ->
+ val clamped = newValue.roundToInt().coerceIn(0, 100)
+ state.sharpnessLevel.value = clamped
+ state.config.value = config.copy(sharpnessLevel = clamped)
+ },
+ valueRange = 0f..100f,
+ )
+ Text(text = "${state.sharpnessLevel.value}%")
+ }
+ Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
+ Text(text = stringResource(R.string.sharpness_denoise))
+ Slider(
+ value = state.sharpnessDenoise.value.toFloat(),
+ onValueChange = { newValue ->
+ val clamped = newValue.roundToInt().coerceIn(0, 100)
+ state.sharpnessDenoise.value = clamped
+ state.config.value = config.copy(sharpnessDenoise = clamped)
+ },
+ valueRange = 0f..100f,
+ )
+ Text(text = "${state.sharpnessDenoise.value}%")
}
}
} else {
diff --git a/app/src/main/java/app/gamenative/ui/component/settings/SettingsEnvVars.kt b/app/src/main/java/app/gamenative/ui/component/settings/SettingsEnvVars.kt
index 9b04163c27..fcb2f5e2b8 100644
--- a/app/src/main/java/app/gamenative/ui/component/settings/SettingsEnvVars.kt
+++ b/app/src/main/java/app/gamenative/ui/component/settings/SettingsEnvVars.kt
@@ -67,6 +67,22 @@ fun SettingsEnvVars(
},
)
}
+ EnvVarSelectionType.SUGGESTIONS -> {
+ SettingsTextFieldWithSuggestions(
+ colors = colors,
+ enabled = enabled,
+ title = { Text(identifier) },
+ value = value,
+ suggestions = envVarInfo?.possibleValues ?: emptyList(),
+ onValueChange = {
+ envVars.put(identifier, it)
+ onEnvVarsChange(envVars)
+ },
+ action = envVarAction?.let {
+ { envVarAction(identifier) }
+ },
+ )
+ }
EnvVarSelectionType.NONE -> {
if (envVarInfo?.possibleValues?.isNotEmpty() == true) {
SettingsListDropdown(
diff --git a/app/src/main/java/app/gamenative/ui/component/settings/SettingsTextFieldWithSuggestions.kt b/app/src/main/java/app/gamenative/ui/component/settings/SettingsTextFieldWithSuggestions.kt
new file mode 100644
index 0000000000..7a19bd456d
--- /dev/null
+++ b/app/src/main/java/app/gamenative/ui/component/settings/SettingsTextFieldWithSuggestions.kt
@@ -0,0 +1,104 @@
+package app.gamenative.ui.component.settings
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.outlined.ViewList
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ListItemDefaults
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.unit.Dp
+import com.alorma.compose.settings.ui.base.internal.LocalSettingsGroupEnabled
+import com.alorma.compose.settings.ui.base.internal.SettingsTileColors
+import com.alorma.compose.settings.ui.base.internal.SettingsTileDefaults
+import com.alorma.compose.settings.ui.base.internal.SettingsTileScaffold
+
+/**
+ * A text field that also offers a dropdown of preset suggestions.
+ * The user can pick a preset to populate the field, or type freely.
+ * Title sits on top, text field + suggestions button sit underneath.
+ */
+@Composable
+fun SettingsTextFieldWithSuggestions(
+ value: String,
+ suggestions: List,
+ title: @Composable (() -> Unit),
+ modifier: Modifier = Modifier,
+ enabled: Boolean = LocalSettingsGroupEnabled.current,
+ icon: @Composable (() -> Unit)? = null,
+ action: @Composable (() -> Unit)? = null,
+ colors: SettingsTileColors = SettingsTileDefaults.colors(),
+ tonalElevation: Dp = ListItemDefaults.Elevation,
+ shadowElevation: Dp = ListItemDefaults.Elevation,
+ onValueChange: (String) -> Unit,
+) {
+ val focusRequester = remember { FocusRequester() }
+ var suggestionsExpanded by remember { mutableStateOf(false) }
+
+ SettingsTileScaffold(
+ modifier = Modifier
+ .clickable(enabled = enabled, onClick = { focusRequester.requestFocus() })
+ .then(modifier),
+ enabled = enabled,
+ title = title,
+ subtitle = {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ OutlinedTextField(
+ modifier = Modifier
+ .focusRequester(focusRequester)
+ .weight(1f),
+ enabled = enabled,
+ value = value,
+ onValueChange = onValueChange,
+ singleLine = true,
+ )
+ IconButton(
+ enabled = enabled,
+ onClick = { suggestionsExpanded = true },
+ ) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Outlined.ViewList,
+ contentDescription = "Presets",
+ )
+ DropdownMenu(
+ expanded = suggestionsExpanded,
+ onDismissRequest = { suggestionsExpanded = false },
+ ) {
+ suggestions.forEach { suggestion ->
+ DropdownMenuItem(
+ text = { Text(suggestion) },
+ onClick = {
+ onValueChange(suggestion)
+ suggestionsExpanded = false
+ },
+ )
+ }
+ }
+ }
+ }
+ },
+ icon = icon,
+ colors = colors,
+ tonalElevation = tonalElevation,
+ shadowElevation = shadowElevation,
+ ) {
+ action?.invoke()
+ }
+}
diff --git a/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt b/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt
index 7870467036..93ff86d364 100644
--- a/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt
+++ b/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt
@@ -500,9 +500,9 @@ class MainViewModel @Inject constructor(
// After app closes, check if we need to show the feedback dialog
// Show feedback if: first time running this game OR config was changed
try {
- // Do not show the Feedback form for non-steam games until we can support.
+ // Show feedback for all stores except custom games.
val feedbackGameSource = ContainerUtils.extractGameSourceFromContainerId(appId)
- if (feedbackGameSource == GameSource.STEAM) {
+ if (feedbackGameSource != GameSource.CUSTOM_GAME) {
val container = ContainerUtils.getContainer(context, appId)
val shown = container.getExtra("discord_support_prompt_shown", "false") == "true"
@@ -522,7 +522,7 @@ class MainViewModel @Inject constructor(
_uiEvent.send(MainUiEvent.ShowGameFeedbackDialog(appId))
}
} else {
- Timber.d("Non-Steam Game Detected, not showing feedback")
+ Timber.d("Custom game detected, not showing feedback")
}
} catch (e: Exception) {
Timber.w(e, "Failed to check/update feedback dialog state for $appId")
diff --git a/app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt
index 71263b7942..d653e75790 100644
--- a/app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt
+++ b/app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt
@@ -847,9 +847,10 @@ private fun LibraryScreenContent(
if (selectedAppId == null) {
// Use Box to allow content to scroll behind the tab bar
Box(modifier = Modifier.fillMaxSize()) {
+ val hasSteamCredentials = PrefManager.refreshToken.isNotEmpty() && PrefManager.username.isNotEmpty()
// When on Steam/GOG/Epic/Amazon tab and not logged in, or LOCAL tab with no custom games, show splash
val showEmptyStateSplash = when (state.currentTab) {
- LibraryTab.STEAM -> !SteamService.isLoggedIn
+ LibraryTab.STEAM -> !hasSteamCredentials && !state.isLoading
LibraryTab.GOG -> !GOGService.hasStoredCredentials(context)
LibraryTab.EPIC -> !EpicService.hasStoredCredentials(context)
LibraryTab.AMAZON -> !AmazonService.hasStoredCredentials(context)
diff --git a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/AmazonAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/AmazonAppScreen.kt
index 88de7ecd3c..4e46bb63d5 100644
--- a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/AmazonAppScreen.kt
+++ b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/AmazonAppScreen.kt
@@ -33,6 +33,7 @@ import app.gamenative.ui.enums.AppOptionMenuType
import app.gamenative.ui.enums.DialogType
import app.gamenative.utils.ContainerUtils
import app.gamenative.utils.DateTimeUtils
+import app.gamenative.utils.MarkerUtils
import com.winlator.container.ContainerData
import com.winlator.core.StringUtils
import kotlinx.coroutines.CoroutineScope
@@ -493,6 +494,9 @@ override fun isInstalled(context: Context, libraryItem: LibraryItem): Boolean =
isVerifying = true
val productId = productIdOf(libraryItem)
CoroutineScope(Dispatchers.IO).launch {
+ AmazonService.getInstallPath(productId)?.let { installPath ->
+ MarkerUtils.clearInstalledPrerequisiteMarkers(installPath)
+ }
val result = AmazonService.verifyGame(context, productId)
withContext(Dispatchers.Main) {
isVerifying = false
diff --git a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt
index 08672c4abe..acd04ea886 100644
--- a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt
+++ b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt
@@ -617,7 +617,11 @@ abstract class BaseAppScreen {
val gameName = ContainerUtils.resolveGameName(libraryItem.appId)
val gpuName = GPUInformation.getRenderer(context)
- val bestConfig = BestConfigService.fetchBestConfig(gameName, gpuName)
+ val bestConfig = BestConfigService.fetchBestConfig(
+ gameName = gameName,
+ gpuName = gpuName,
+ gameStore = libraryItem.gameSource.name,
+ )
if (bestConfig == null) {
SnackbarManager.show(context.getString(R.string.best_config_fetch_failed))
return
@@ -641,6 +645,7 @@ abstract class BaseAppScreen {
configJson = bestConfig.bestConfig,
matchType = bestConfig.matchType,
applyKnownConfig = true,
+ storeMatch = bestConfig.matchedStore.equals(libraryItem.gameSource.name, ignoreCase = true),
)
val missingContentDescription = BestConfigService.consumeLastMissingContentDescription()
diff --git a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/GOGAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/GOGAppScreen.kt
index cf186577cd..122771a6bf 100644
--- a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/GOGAppScreen.kt
+++ b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/GOGAppScreen.kt
@@ -17,7 +17,6 @@ import androidx.compose.ui.res.stringResource
import app.gamenative.R
import app.gamenative.data.GOGGame
import app.gamenative.data.LibraryItem
-import app.gamenative.enums.Marker
import app.gamenative.service.gog.GOGConstants
import app.gamenative.service.gog.GOGService
import app.gamenative.utils.MarkerUtils
@@ -411,16 +410,20 @@ class GOGAppScreen : BaseAppScreen() {
Timber.tag(TAG).d("saveContainerConfig: saved container config for ${libraryItem.appId}")
if (previousLanguage != config.language) {
- CoroutineScope(Dispatchers.IO).launch {
- val gameId = libraryItem.gameId.toString()
- if (!GOGService.isGameInstalled(gameId)) return@launch
- if (GOGService.getDownloadInfo(gameId)?.isActive() == true) return@launch
+ triggerGOGVerifyDownload(context, libraryItem, config.language)
+ }
+ }
+
+ private fun triggerGOGVerifyDownload(context: Context, libraryItem: LibraryItem, language: String) {
+ CoroutineScope(Dispatchers.IO).launch {
+ val gameId = libraryItem.gameId.toString()
+ if (!GOGService.isGameInstalled(gameId)) return@launch
+ if (GOGService.getDownloadInfo(gameId)?.isActive() == true) return@launch
- val installPath = GOGService.getInstallPath(gameId)
- ?: GOGConstants.getGameInstallPath(libraryItem.name)
+ val installPath = GOGService.getInstallPath(gameId)
+ ?: GOGConstants.getGameInstallPath(libraryItem.name)
- GOGService.downloadGame(context, gameId, installPath, config.language)
- }
+ GOGService.downloadGame(context, gameId, installPath, language)
}
}
@@ -430,6 +433,39 @@ class GOGAppScreen : BaseAppScreen() {
return true
}
+ @Composable
+ override fun getSourceSpecificMenuOptions(
+ context: Context,
+ libraryItem: LibraryItem,
+ onEditContainer: () -> Unit,
+ onBack: () -> Unit,
+ onClickPlay: (Boolean) -> Unit,
+ isInstalled: Boolean,
+ ): List {
+ if (!isInstalled || isDownloading(context, libraryItem)) {
+ return emptyList()
+ }
+
+ return listOf(
+ AppMenuOption(
+ optionType = AppOptionMenuType.VerifyFiles,
+ onClick = {
+ showInstallDialog(
+ libraryItem.appId,
+ app.gamenative.ui.component.dialog.state.MessageDialogState(
+ visible = true,
+ type = app.gamenative.ui.enums.DialogType.UPDATE_VERIFY_CONFIRM,
+ title = context.getString(R.string.library_verify_files_title),
+ message = context.getString(R.string.library_verify_files_message),
+ confirmBtnText = context.getString(R.string.proceed),
+ dismissBtnText = context.getString(R.string.cancel),
+ ),
+ )
+ },
+ ),
+ )
+ }
+
/**
* GOG games support standard container reset
*/
@@ -599,6 +635,17 @@ class GOGAppScreen : BaseAppScreen() {
}
}
}
+ app.gamenative.ui.enums.DialogType.UPDATE_VERIFY_CONFIRM -> {
+ {
+ BaseAppScreen.hideInstallDialog(appId)
+ val gameId = libraryItem.gameId.toString()
+ val installPath = GOGService.getInstallPath(gameId)
+ ?: GOGConstants.getGameInstallPath(libraryItem.name)
+ MarkerUtils.clearInstalledPrerequisiteMarkers(installPath)
+ val language = loadContainerData(context, libraryItem).language
+ triggerGOGVerifyDownload(context, libraryItem, language)
+ }
+ }
else -> null
}
app.gamenative.ui.component.dialog.MessageDialog(
diff --git a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt
index d46f3dd2e4..2b4f8db97d 100644
--- a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt
+++ b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt
@@ -1057,6 +1057,7 @@ class SteamAppScreen : BaseAppScreen() {
MarkerUtils.removeMarker(getAppDirPath(gameId), Marker.STEAM_COLDCLIENT_USED)
if (operation == AppOptionMenuType.VerifyFiles) {
+ MarkerUtils.clearInstalledPrerequisiteMarkers(getAppDirPath(gameId))
val steamId = SteamService.userSteamId
if (steamId != null) {
val prefixToPath: (String) -> String = { prefix ->
diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryCarouselPane.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryCarouselPane.kt
index 06ce6af15a..8a826fe84a 100644
--- a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryCarouselPane.kt
+++ b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryCarouselPane.kt
@@ -2,9 +2,6 @@ package app.gamenative.ui.screen.library.components
import android.view.KeyEvent
import androidx.compose.foundation.ExperimentalFoundationApi
-import androidx.compose.foundation.gestures.Orientation
-import androidx.compose.foundation.gestures.draggable
-import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
import androidx.compose.foundation.layout.Arrangement
@@ -50,6 +47,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.pointer.PointerEventType
+import androidx.compose.ui.input.pointer.PointerType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
@@ -88,17 +86,22 @@ private const val CAROUSEL_CARD_SIZE_MULTIPLIER = 1.22f
private const val CAROUSEL_CARD_VERTICAL_OVERFLOW = 32f
private const val CAROUSEL_BADGE_RESERVED_HEIGHT = 0f
private const val CAROUSEL_MOUSE_WHEEL_SCROLL_MULTIPLIER = 72f
+private const val CAROUSEL_MOUSE_DRAG_SLOP_PX = 8f
private fun Modifier.carouselMouseInput(listState: LazyListState): Modifier =
pointerInput(listState) {
coroutineScope {
awaitPointerEventScope {
+ var isDragging = false
+ var lastDragX = 0f
+ var pressedStartX: Float? = null
while (true) {
val event = awaitPointerEvent()
+ val mouseChange = event.changes.firstOrNull { it.type == PointerType.Mouse }
when (event.type) {
PointerEventType.Scroll -> {
- val scrollDelta = event.changes.firstOrNull()?.scrollDelta
+ val scrollDelta = mouseChange?.scrollDelta
if (scrollDelta != null) {
val dominantDelta =
if (abs(scrollDelta.x) > abs(scrollDelta.y)) scrollDelta.x else scrollDelta.y
@@ -112,6 +115,37 @@ private fun Modifier.carouselMouseInput(listState: LazyListState): Modifier =
}
}
+ PointerEventType.Press -> {
+ if (mouseChange?.pressed == true) {
+ pressedStartX = mouseChange.position.x
+ isDragging = false
+ }
+ }
+
+ PointerEventType.Move -> {
+ if (mouseChange != null) {
+ val currentX = mouseChange.position.x
+ if (isDragging) {
+ val delta = currentX - lastDragX
+ lastDragX = currentX
+ if (delta != 0f) {
+ listState.dispatchRawDelta(-delta)
+ }
+ } else {
+ val startX = pressedStartX
+ if (startX != null && abs(currentX - startX) > CAROUSEL_MOUSE_DRAG_SLOP_PX) {
+ isDragging = true
+ lastDragX = currentX
+ }
+ }
+ }
+ }
+
+ PointerEventType.Release, PointerEventType.Exit -> {
+ isDragging = false
+ pressedStartX = null
+ }
+
else -> Unit
}
}
@@ -330,19 +364,11 @@ internal fun LibraryCarouselPane(
if (state.appInfoList.isNotEmpty()) {
val flingBehavior = rememberSnapFlingBehavior(lazyListState = listState)
- val mouseDragState = rememberDraggableState { delta ->
- listState.dispatchRawDelta(-delta)
- }
-
LazyRow(
state = listState,
modifier = Modifier
.fillMaxSize()
- .carouselMouseInput(listState)
- .draggable(
- state = mouseDragState,
- orientation = Orientation.Horizontal,
- ),
+ .carouselMouseInput(listState),
flingBehavior = flingBehavior,
horizontalArrangement = Arrangement.spacedBy(carouselItemSpacing),
verticalAlignment = Alignment.CenterVertically,
diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/ambient/AmbientDownloadOverlay.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/ambient/AmbientDownloadOverlay.kt
index 609fded888..0e2e1fb70e 100644
--- a/app/src/main/java/app/gamenative/ui/screen/library/components/ambient/AmbientDownloadOverlay.kt
+++ b/app/src/main/java/app/gamenative/ui/screen/library/components/ambient/AmbientDownloadOverlay.kt
@@ -46,7 +46,6 @@ import app.gamenative.ui.screen.library.components.ambient.AmbientModeConstants.
import app.gamenative.ui.screen.library.components.ambient.AmbientModeConstants.SHIMMER_PERIOD_MS
import app.gamenative.ui.screen.library.components.ambient.AmbientModeConstants.SHIMMER_WIDTH_FRACTION
import app.gamenative.ui.theme.BrandGradient
-import app.gamenative.utils.BrightnessManager
import app.gamenative.utils.ShakeDetector
import kotlinx.coroutines.delay
@@ -63,7 +62,6 @@ internal fun AmbientDownloadOverlay(
) {
val activity = LocalActivity.current as? ComponentActivity ?: return
val context = LocalContext.current
- val brightnessManager = remember { BrightnessManager(activity, AmbientModeConstants.MIN_BRIGHTNESS) }
var interactionCounter by remember { mutableIntStateOf(0) }
var isIdle by remember { mutableStateOf(false) }
@@ -131,19 +129,6 @@ internal fun AmbientDownloadOverlay(
label = "ambientAlpha",
)
- LaunchedEffect(isIdle) {
- if (isIdle) {
- delay(AmbientModeConstants.BRIGHTNESS_DIM_DELAY_MS)
- brightnessManager.dim()
- } else {
- brightnessManager.restore()
- }
- }
-
- DisposableEffect(Unit) {
- onDispose { brightnessManager.restore() }
- }
-
if (ambientAlpha > 0f) {
val infiniteTransition = rememberInfiniteTransition(label = "ambient")
diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/ambient/AmbientModeConstants.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/ambient/AmbientModeConstants.kt
index 05be886c6a..97824ce93b 100644
--- a/app/src/main/java/app/gamenative/ui/screen/library/components/ambient/AmbientModeConstants.kt
+++ b/app/src/main/java/app/gamenative/ui/screen/library/components/ambient/AmbientModeConstants.kt
@@ -7,8 +7,6 @@ internal object AmbientModeConstants {
const val SHIMMER_PERIOD_MS = 2_000
const val DRIFT_PERIOD_MS = 30_000
const val DRIFT_AMPLITUDE_PX = 16f
- const val BRIGHTNESS_DIM_DELAY_MS = 30_000L
- const val MIN_BRIGHTNESS = 0.01f
const val BAR_HEIGHT_DP = 4f
const val BAR_BASE_ALPHA = 0.25f
const val BAR_TRACK_ALPHA = 0.15f
diff --git a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt
index aa475f2a3f..4a0ea44887 100644
--- a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt
+++ b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt
@@ -1104,7 +1104,11 @@ fun XServerScreen(
gameBack()
handled = true
} else {
- handled = keyboard?.onKeyEvent(it.event) == true
+ if (it.event.device?.isVirtual == true) {
+ handled = keyboard?.onVirtualKeyEvent(it.event) == true
+ } else {
+ handled = keyboard?.onKeyEvent(it.event) == true
+ }
}
}
handled
@@ -1125,7 +1129,7 @@ fun XServerScreen(
if (!handled) handled = xServerView!!.getxServer().winHandler.onGenericMotionEvent(it.event)
}
if (PluviaApp.touchpadView?.hasPointerCapture() != true && !PluviaApp.isOverlayPaused) {
- if (it.event != null) {
+ if ((it.event != null) && (it.event.device != null)) {
val device = it.event.device
val isExternal = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) device.isExternal else true
if (device.supportsSource(InputDevice.SOURCE_TOUCHPAD) &&
@@ -1400,23 +1404,71 @@ fun XServerScreen(
getxServer().windowManager.removeOnWindowModificationListener(it)
}
val wmListener = object : WindowManager.OnWindowModificationListener {
+ private fun isFrameRatingCandidateProperty(propertyName: String): Boolean {
+ return propertyName.contains("_UTIL_LAYER") ||
+ propertyName.contains("_MESA_DRV") ||
+ (container.containerVariant.equals(Container.GLIBC) && propertyName.contains("_NET_WM_SURFACE"))
+ }
+
+ private fun describeFrameRatingWindow(window: Window): String {
+ return "id=${window.id}, name=${window.name}, class=${window.className}, pid=${window.processId}"
+ }
+
private fun changeFrameRatingVisibility(window: Window, property: Property?) {
- if (frameRating == null) return
+ val rating = frameRating ?: return
if (property != null) {
- if (frameRatingWindowId == -1 && (
- property.nameAsString().contains("_UTIL_LAYER") ||
- property.nameAsString().contains("_MESA_DRV") ||
- container.containerVariant.equals(Container.GLIBC) && property.nameAsString().contains("_NET_WM_SURFACE"))) {
- frameRatingWindowId = window.id
+ val propertyName = property.nameAsString()
+ if (!isFrameRatingCandidateProperty(propertyName)) return
+
+ when {
+ frameRatingWindowId == -1 -> {
+ frameRatingWindowId = window.id
+ Timber.i(
+ "FrameRating tracking attached via property=%s to %s",
+ propertyName,
+ describeFrameRatingWindow(window),
+ )
+ (context as? Activity)?.runOnUiThread {
+ frameRating?.visibility = View.VISIBLE
+ }
+ rating.update()
+ }
+ frameRatingWindowId == window.id -> {
+ Timber.d(
+ "FrameRating received candidate property=%s for already tracked window %s",
+ propertyName,
+ describeFrameRatingWindow(window),
+ )
+ }
+ else -> {
+ Timber.d(
+ "FrameRating ignoring candidate property=%s for %s because tracking already points to windowId=%d",
+ propertyName,
+ describeFrameRatingWindow(window),
+ frameRatingWindowId,
+ )
+ }
+ }
+ return
+ }
+
+ when {
+ frameRatingWindowId == window.id -> {
+ Timber.i(
+ "FrameRating tracking cleared because tracked window unmapped: %s",
+ describeFrameRatingWindow(window),
+ )
+ frameRatingWindowId = -1
(context as? Activity)?.runOnUiThread {
- frameRating?.visibility = View.VISIBLE
+ frameRating?.visibility = View.GONE
}
- frameRating?.update()
}
- } else if (frameRatingWindowId != -1) {
- frameRatingWindowId = -1
- (context as? Activity)?.runOnUiThread {
- frameRating?.visibility = View.GONE
+ frameRatingWindowId != -1 -> {
+ Timber.d(
+ "FrameRating ignoring unmap for non-tracked window %s; still tracking windowId=%d",
+ describeFrameRatingWindow(window),
+ frameRatingWindowId,
+ )
}
}
}
@@ -1433,6 +1485,13 @@ fun XServerScreen(
}
override fun onModifyWindowProperty(window: Window, property: Property) {
+ if (frameRating != null && isFrameRatingCandidateProperty(property.nameAsString())) {
+ Timber.d(
+ "FrameRating observed candidate property=%s on %s",
+ property.nameAsString(),
+ describeFrameRatingWindow(window),
+ )
+ }
changeFrameRatingVisibility(window, property)
}
@@ -2288,7 +2347,6 @@ private fun showInputControls(profile: ControlsProfile, winHandler: WinHandler,
}
PluviaApp.touchpadView?.setSensitivity(profile.getCursorSpeed() * 1.0f)
- PluviaApp.touchpadView?.setPointerButtonRightEnabled(false)
// If the selected profile is a virtual gamepad, we must enable the P1 slot.
@@ -3734,6 +3792,22 @@ private fun setupWineSystemFiles(
containerDataChanged = true
}
+ // OpenAL audio: extract native DLLs if WINEDLLOVERRIDES mentions openal32 or soft_oal
+ val dllOverrides = EnvVars(container.envVars).get("WINEDLLOVERRIDES")
+ val needsOpenalDlls = dllOverrides.contains("openal32") || dllOverrides.contains("soft_oal")
+ val openalState = if (needsOpenalDlls) "yes" else "no"
+ if (openalState != container.getExtra("openal_dlls") || firstTimeBoot) {
+ if (needsOpenalDlls) {
+ val windowsDir = File(imageFs.rootDir, ImageFs.WINEPREFIX + "/drive_c/windows")
+ TarCompressorUtils.extract(
+ TarCompressorUtils.Type.ZSTD, context.assets,
+ "wincomponents/openal.tzst", windowsDir, onExtractFileListener,
+ )
+ }
+ container.putExtra("openal_dlls", openalState)
+ containerDataChanged = true
+ }
+
if (container.isLaunchRealSteam){
extractSteamFiles(context, container, onExtractFileListener)
}
@@ -4408,6 +4482,8 @@ private fun changeWineAudioDriver(audioDriver: String, container: Container, ima
registryEditor.setStringValue("Software\\Wine\\Drivers", "Audio", "alsa")
} else if (audioDriver == "pulseaudio") {
registryEditor.setStringValue("Software\\Wine\\Drivers", "Audio", "pulse")
+ } else if (audioDriver == "disabled") {
+ registryEditor.setStringValue("Software\\Wine\\Drivers", "Audio", "")
}
}
container.putExtra("audioDriver", audioDriver)
diff --git a/app/src/main/java/app/gamenative/utils/BestConfigService.kt b/app/src/main/java/app/gamenative/utils/BestConfigService.kt
index 878d3a1dc2..f294c64299 100644
--- a/app/src/main/java/app/gamenative/utils/BestConfigService.kt
+++ b/app/src/main/java/app/gamenative/utils/BestConfigService.kt
@@ -49,7 +49,8 @@ object BestConfigService {
val bestConfig: JsonObject,
val matchType: String, // "exact_gpu_match" | "gpu_family_match" | "fallback_match" | "no_match"
val matchedGpu: String,
- val matchedDeviceId: Int
+ val matchedDeviceId: Int,
+ val matchedStore: String,
)
/**
@@ -72,9 +73,10 @@ object BestConfigService {
*/
suspend fun fetchBestConfig(
gameName: String,
- gpuName: String
+ gpuName: String,
+ gameStore: String,
): BestConfigResponse? = withContext(Dispatchers.IO) {
- val cacheKey = "${gameName}_${gpuName}"
+ val cacheKey = "${gameName}_${gpuName}_${gameStore}"
// Check cache first
cache[cacheKey]?.let {
@@ -86,6 +88,7 @@ object BestConfigService {
val requestBody = JSONObject().apply {
put("gameName", gameName)
put("gpuName", gpuName)
+ put("game_store", gameStore)
}
val attestation = KeyAttestationHelper.getAttestationFields("https://api.gamenative.app")
@@ -126,7 +129,8 @@ object BestConfigService {
bestConfig = bestConfig,
matchType = jsonResponse.getString("matchType"),
matchedGpu = jsonResponse.getString("matchedGpu"),
- matchedDeviceId = jsonResponse.getInt("matchedDeviceId")
+ matchedDeviceId = jsonResponse.getInt("matchedDeviceId"),
+ matchedStore = jsonResponse.optString("matchedStore", gameStore),
)
cache[cacheKey] = bestConfigResponse
@@ -171,9 +175,13 @@ object BestConfigService {
* Filters config JSON based on match type.
* For fallback_match, excludes containerVariant, graphicsDriver, dxwrapper, and dxwrapperConfig.
*/
- fun filterConfigByMatchType(config: JsonObject, matchType: String): JsonObject {
+ fun filterConfigByMatchType(config: JsonObject, matchType: String, storeMatch: Boolean = true): JsonObject {
val filtered = config.toMutableMap()
+ if (!storeMatch) {
+ filtered.remove("executablePath")
+ }
+
if (matchType == "exact_gpu_match" || matchType == "gpu_family_match") {
// Apply all fields
return JsonObject(filtered)
@@ -630,7 +638,13 @@ object BestConfigService {
* First parses values (using PrefManager defaults for validation), then validates component versions.
* Returns map with only fields present in config (no defaults), or empty map if validation fails.
*/
- suspend fun parseConfigToContainerData(context: Context, configJson: JsonObject, matchType: String, applyKnownConfig: Boolean): Map? {
+ suspend fun parseConfigToContainerData(
+ context: Context,
+ configJson: JsonObject,
+ matchType: String,
+ applyKnownConfig: Boolean,
+ storeMatch: Boolean = true,
+ ): Map? {
try {
val originalJson = JSONObject(configJson.toString())
@@ -695,7 +709,7 @@ object BestConfigService {
// Step 1: Filter config based on match type
val updatedConfigJson = Json.parseToJsonElement(originalJson.toString()).jsonObject
- val filteredConfig = filterConfigByMatchType(updatedConfigJson, matchType)
+ val filteredConfig = filterConfigByMatchType(updatedConfigJson, matchType, storeMatch)
val filteredJson = JSONObject(filteredConfig.toString())
// Step 2: Validate component versions against resource arrays
diff --git a/app/src/main/java/app/gamenative/utils/CaseInsensitiveFileSystem.kt b/app/src/main/java/app/gamenative/utils/CaseInsensitiveFileSystem.kt
index 759f1bb099..27bed1f73b 100644
--- a/app/src/main/java/app/gamenative/utils/CaseInsensitiveFileSystem.kt
+++ b/app/src/main/java/app/gamenative/utils/CaseInsensitiveFileSystem.kt
@@ -11,40 +11,47 @@ import java.util.concurrent.ConcurrentHashMap
* when Steam depot manifests use different casing than what's already installed
* (e.g. DLC referencing `_Work` when the base game created `_work`).
*
- * Resolved segments are cached for the lifetime of this instance (one download
- * session) so repeated lookups for the same parent+segment are O(1).
+ * Two-level cache: full-path results are cached so repeat operations on the same
+ * path (common during chunk writes) skip per-segment resolution entirely.
+ * Per-segment results are cached in a nested map so different paths sharing a
+ * common prefix reuse earlier resolution work without allocating keys.
*/
class CaseInsensitiveFileSystem(
delegate: FileSystem = SYSTEM,
) : ForwardingFileSystem(delegate) {
- // (parent, lowercased segment) → resolved child path.
- // keyed by lowercase so all casing variants ("Saves", "saves", "SAVES") hit
- // the same entry. computeIfAbsent is atomic on ConcurrentHashMap, so
- // concurrent threads won't race to create duplicate directories.
- private val cache = ConcurrentHashMap, Path>()
+ // full path → resolved path. most calls hit this and return immediately.
+ private val pathCache = ConcurrentHashMap()
+
+ // parent → (lowercase segment → resolved child path).
+ // nested map avoids key concatenation/Pair allocation on every lookup.
+ private val segmentCache = ConcurrentHashMap>()
+
+ // segment string → its lowercase form. game paths reuse a small vocabulary
+ // of directory names, so this prevents repeated lowercase() allocation.
+ private val lowercasePool = ConcurrentHashMap()
override fun onPathParameter(path: Path, functionName: String, parameterName: String): Path {
+ pathCache[path]?.let { return it }
+
val root = path.root ?: return path
var resolved = root
for (segment in path.segments) {
- val key = resolved to segment.lowercase()
- resolved = cache.computeIfAbsent(key) {
- // fast path: exact casing exists
- val exact = resolved / segment
+ val lower = lowercasePool.computeIfAbsent(segment) { it.lowercase() }
+ val parent = resolved
+ val children = segmentCache.computeIfAbsent(parent) { ConcurrentHashMap() }
+ resolved = children.computeIfAbsent(lower) {
+ val exact = parent / segment
if (delegate.metadataOrNull(exact) != null) {
exact
} else {
- // slow path: list parent and match case-insensitively.
- // if multiple entries match (e.g. leftover _Work + _work from a
- // prior bug), the first one returned by the filesystem wins —
- // non-deterministic but unavoidable without deeper heuristics.
- delegate.listOrNull(resolved)
+ delegate.listOrNull(parent)
?.firstOrNull { it.name.equals(segment, ignoreCase = true) }
?: exact
}
}
}
+ pathCache[path] = resolved
return resolved
}
}
diff --git a/app/src/main/java/app/gamenative/utils/ContainerStorageManager.kt b/app/src/main/java/app/gamenative/utils/ContainerStorageManager.kt
index 8e5a454513..f9b86c79f9 100644
--- a/app/src/main/java/app/gamenative/utils/ContainerStorageManager.kt
+++ b/app/src/main/java/app/gamenative/utils/ContainerStorageManager.kt
@@ -6,7 +6,9 @@ import app.gamenative.PrefManager
import app.gamenative.data.GameSource
import app.gamenative.data.LibraryItem
import app.gamenative.data.SteamApp
+import app.gamenative.data.AppInfo
import app.gamenative.db.dao.AmazonGameDao
+import app.gamenative.db.dao.AppInfoDao
import app.gamenative.db.dao.EpicGameDao
import app.gamenative.db.dao.GOGGameDao
import app.gamenative.db.dao.SteamAppDao
@@ -43,6 +45,7 @@ object ContainerStorageManager {
@InstallIn(SingletonComponent::class)
interface StorageManagerDaoEntryPoint {
fun steamAppDao(): SteamAppDao
+ fun appInfoDao(): AppInfoDao
fun gogGameDao(): GOGGameDao
fun epicGameDao(): EpicGameDao
fun amazonGameDao(): AmazonGameDao
@@ -425,7 +428,7 @@ object ContainerStorageManager {
val installedGames = linkedMapOf()
runCatching {
- loadSteamInstalledGames(entryPoint.steamAppDao())
+ loadSteamInstalledGames(entryPoint.steamAppDao(), entryPoint.appInfoDao())
}.onSuccess { games ->
games.forEach { installedGames[it.appId] = it }
}.onFailure { e ->
@@ -524,19 +527,41 @@ object ContainerStorageManager {
return installedGames
}
- private suspend fun loadSteamInstalledGames(steamAppDao: SteamAppDao): List {
- return steamAppDao.getAllOwnedAppsAsList()
+ private suspend fun loadSteamInstalledGames(steamAppDao: SteamAppDao, appInfoDao: AppInfoDao): List {
+ val appInfosById = appInfoDao.getAll().associateBy { it.id }
+ val recoveredAppInfos = mutableListOf()
+
+ val installedGames = steamAppDao.getAllOwnedAppsAsList()
.mapNotNull { app ->
val installPath = resolveSteamInstallPath(app) ?: return@mapNotNull null
+ val appInfo = appInfosById[app.id]
+ val installSizeBytes = estimateSteamInstallSize(app)
+ ?: appInfo?.recoveredInstallSizeBytes?.takeIf { it > 0L }
+ ?: getDirectorySizeIfPresent(installPath)?.also { recoveredSizeBytes ->
+ buildRecoveredSteamInstallSizeAppInfo(appInfo, app.id, recoveredSizeBytes)?.let { updatedAppInfo ->
+ recoveredAppInfos += updatedAppInfo
+ }
+ Timber.tag("ContainerStorageManager").i(
+ "Recovered and cached on-disk Steam size for %s (%s) because installed depot metadata is unavailable",
+ app.id,
+ installPath,
+ )
+ }
InstalledGame(
appId = "${GameSource.STEAM.name}_${app.id}",
displayName = app.name.ifBlank { app.id.toString() },
gameSource = GameSource.STEAM,
installPath = installPath,
iconUrl = app.clientIconUrl.takeIf { app.clientIconHash.isNotEmpty() }.orEmpty(),
- installSizeBytes = estimateSteamInstallSize(app),
+ installSizeBytes = installSizeBytes,
)
}
+
+ if (recoveredAppInfos.isNotEmpty()) {
+ appInfoDao.insertAll(recoveredAppInfos)
+ }
+
+ return installedGames
}
private fun resolveSteamInstallPath(app: SteamApp): String? {
@@ -795,6 +820,29 @@ object ContainerStorageManager {
.takeIf { it > 0L }
}
+ private fun getDirectorySizeIfPresent(path: String): Long? {
+ val dir = File(path)
+ if (!dir.exists() || !dir.isDirectory) return null
+ return getContainerDirectorySize(dir.toPath()).takeIf { it > 0L }
+ }
+
+ private fun buildRecoveredSteamInstallSizeAppInfo(
+ existingAppInfo: AppInfo?,
+ appId: Int,
+ recoveredSizeBytes: Long,
+ ): AppInfo? {
+ if (recoveredSizeBytes <= 0L || existingAppInfo?.recoveredInstallSizeBytes == recoveredSizeBytes) return null
+
+ return existingAppInfo?.copy(
+ isDownloaded = true,
+ recoveredInstallSizeBytes = recoveredSizeBytes,
+ ) ?: AppInfo(
+ id = appId,
+ isDownloaded = true,
+ recoveredInstallSizeBytes = recoveredSizeBytes,
+ )
+ }
+
private fun readConfig(configFile: File): JSONObject? {
if (!configFile.exists() || !configFile.isFile) return null
return try {
diff --git a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt
index 23668f2b8b..2c3e7b0ebe 100644
--- a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt
+++ b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt
@@ -762,7 +762,11 @@ object ContainerUtils {
// If not cached, make request on background thread (not UI thread)
runBlocking(Dispatchers.IO) {
try {
- val bestConfig = BestConfigService.fetchBestConfig(gameName, gpuName)
+ val bestConfig = BestConfigService.fetchBestConfig(
+ gameName = gameName,
+ gpuName = gpuName,
+ gameStore = gameSource.name,
+ )
if (bestConfig != null && bestConfig.matchType != "no_match") {
Timber.i("Applying best config for $gameName (matchType: ${bestConfig.matchType})")
val parsedConfig = BestConfigService.parseConfigToContainerData(
@@ -770,6 +774,7 @@ object ContainerUtils {
bestConfig.bestConfig,
bestConfig.matchType,
true,
+ bestConfig.matchedStore.equals(gameSource.name, ignoreCase = true),
)
if (parsedConfig != null && parsedConfig.isNotEmpty()) {
bestConfigMap = parsedConfig
diff --git a/app/src/main/java/app/gamenative/utils/KeyValueUtils.kt b/app/src/main/java/app/gamenative/utils/KeyValueUtils.kt
index b4cdeee142..0e6132398b 100644
--- a/app/src/main/java/app/gamenative/utils/KeyValueUtils.kt
+++ b/app/src/main/java/app/gamenative/utils/KeyValueUtils.kt
@@ -58,6 +58,7 @@ fun KeyValue.generateSteamApp(): SteamApp {
encryptedManifests = encryptedManifests,
language = currentDepot["config"]["language"].value.orEmpty(),
realm = currentDepot["config"]["realm"].value.orEmpty(),
+ systemDefined = currentDepot["systemdefined"].asBoolean(),
optionalDlcId = currentDepot["config"]["optionaldlc"].asInteger(INVALID_APP_ID),
steamDeck = currentDepot["config"]["steamdeck"].asBoolean(false),
)
diff --git a/app/src/main/java/app/gamenative/utils/MarkerUtils.kt b/app/src/main/java/app/gamenative/utils/MarkerUtils.kt
index 2f06c70e91..d4eb26a0da 100644
--- a/app/src/main/java/app/gamenative/utils/MarkerUtils.kt
+++ b/app/src/main/java/app/gamenative/utils/MarkerUtils.kt
@@ -7,6 +7,14 @@ import java.io.File
object MarkerUtils {
private const val DOWNLOAD_INFO_DIR = ".DownloadInfo"
private const val BYTES_DOWNLOADED_FILE = "bytes_downloaded.txt"
+ private val VERIFY_PREREQUISITE_MARKERS = listOf(
+ Marker.VCREDIST_INSTALLED,
+ Marker.GOG_SCRIPT_INSTALLED,
+ Marker.PHYSX_INSTALLED,
+ Marker.OPENAL_INSTALLED,
+ Marker.XNA_INSTALLED,
+ Marker.UBISOFT_CONNECT_INSTALLED,
+ )
fun hasMarker(dirPath: String, type: Marker): Boolean {
return File(dirPath, type.fileName).exists()
@@ -72,4 +80,12 @@ object MarkerUtils {
// Nothing to delete
return true
}
+
+ /**
+ * Clears marker files that represent completed prerequisite installs.
+ * This is used by "Verify Files" flows so prerequisites can run again.
+ */
+ fun clearInstalledPrerequisiteMarkers(dirPath: String) {
+ VERIFY_PREREQUISITE_MARKERS.forEach { removeMarker(dirPath, it) }
+ }
}
diff --git a/app/src/main/java/app/gamenative/utils/SteamUtils.kt b/app/src/main/java/app/gamenative/utils/SteamUtils.kt
index e1de1d9ee4..ad7f6d3be3 100644
--- a/app/src/main/java/app/gamenative/utils/SteamUtils.kt
+++ b/app/src/main/java/app/gamenative/utils/SteamUtils.kt
@@ -963,8 +963,17 @@ object SteamUtils {
}
}
- // Add cloud save config sections if appInfo exists
+ // Add app paths and cloud save config sections if appInfo exists
if (appInfo != null) {
+ // Some games required this path to be setup for detecting dlc, e.g. Vampire Survivors
+ val gameDir = File(SteamService.getAppDirPath(steamAppId))
+ val gameName = gameDir.name
+ val actualInstallDir = appInfo.config.installDir.ifEmpty { gameName }
+ appendLine()
+ appendLine("[app::paths]")
+ appendLine("$steamAppId=./steamapps/common/$actualInstallDir")
+
+ // Setup for cloud save
appendLine()
append(generateCloudSaveConfig(appInfo))
}
diff --git a/app/src/main/java/app/gamenative/utils/preInstallSteps/UbisoftConnectStep.kt b/app/src/main/java/app/gamenative/utils/preInstallSteps/UbisoftConnectStep.kt
index 2c3ff59c12..09b4054863 100644
--- a/app/src/main/java/app/gamenative/utils/preInstallSteps/UbisoftConnectStep.kt
+++ b/app/src/main/java/app/gamenative/utils/preInstallSteps/UbisoftConnectStep.kt
@@ -9,9 +9,8 @@ import timber.log.Timber
object UbisoftConnectStep : PreInstallStep {
private const val TAG = "UbisoftConnectStep"
- private const val INSTALLER_NAME = "UbisoftConnectInstaller.exe"
+ private val INSTALLER_NAMES = listOf("UbisoftConnectInstaller.exe", "UplayInstaller.exe")
private const val COMMON_REDIST_SUBDIR = "_CommonRedist/UbisoftConnect"
- private const val WINE_INSTALLER_PATH = "A:\\_CommonRedist\\UbisoftConnect\\UbisoftConnectInstaller.exe"
override val marker: Marker = Marker.UBISOFT_CONNECT_INSTALLED
@@ -32,20 +31,27 @@ object UbisoftConnectStep : PreInstallStep {
gameDir: File,
gameDirPath: String,
): String? {
- val rootInstaller = File(gameDir, INSTALLER_NAME)
val commonRedistDir = File(gameDir, COMMON_REDIST_SUBDIR)
- val commonRedistInstaller = File(commonRedistDir, INSTALLER_NAME)
+ val installerName =
+ INSTALLER_NAMES.firstOrNull { installer ->
+ ensureInstallerAtCommonRedist(
+ rootInstaller = File(gameDir, installer),
+ commonRedistDir = commonRedistDir,
+ commonRedistInstaller = File(commonRedistDir, installer),
+ )
+ }
- if (!ensureInstallerAtCommonRedist(rootInstaller, commonRedistDir, commonRedistInstaller)) {
+ if (installerName == null) {
Timber.tag(TAG).i(
- "Ubisoft Connect installer not present at expected _CommonRedist path for game at %s",
+ "Ubisoft installer not present at expected _CommonRedist path for game at %s",
gameDirPath,
)
return null
}
- val command = "$WINE_INSTALLER_PATH /S"
- Timber.tag(TAG).i("Using Ubisoft Connect installer (silent): %s", command)
+ val wineInstallerPath = "A:\\_CommonRedist\\UbisoftConnect\\$installerName"
+ val command = "$wineInstallerPath /S"
+ Timber.tag(TAG).i("Using Ubisoft installer (silent): %s", command)
return command
}
@@ -68,7 +74,7 @@ object UbisoftConnectStep : PreInstallStep {
Files.createSymbolicLink(commonRedistInstaller.toPath(), rootInstaller.toPath())
return commonRedistInstaller.exists()
} catch (t: Throwable) {
- Timber.tag(TAG).w(t, "Failed creating Ubisoft Connect symlink")
+ Timber.tag(TAG).w(t, "Failed creating Ubisoft symlink")
return false
}
}
diff --git a/app/src/main/java/com/winlator/container/Container.java b/app/src/main/java/com/winlator/container/Container.java
index dc2e973b14..8be5478700 100644
--- a/app/src/main/java/com/winlator/container/Container.java
+++ b/app/src/main/java/com/winlator/container/Container.java
@@ -34,7 +34,7 @@ public enum XrControllerMapping {
public static final String EXTERNAL_DISPLAY_MODE_HYBRID = "hybrid";
public static final String DEFAULT_EXTERNAL_DISPLAY_MODE = EXTERNAL_DISPLAY_MODE_OFF;
- public static final String DEFAULT_ENV_VARS = "WRAPPER_MAX_IMAGE_COUNT=0 ZINK_DESCRIPTORS=lazy ZINK_DEBUG=compact MESA_SHADER_CACHE_DISABLE=false MESA_SHADER_CACHE_MAX_SIZE=512MB mesa_glthread=true WINEESYNC=1 MESA_VK_WSI_PRESENT_MODE=mailbox TU_DEBUG=noconform DXVK_FRAME_RATE=60 PULSE_LATENCY_MSEC=144";
+ public static final String DEFAULT_ENV_VARS = "WRAPPER_MAX_IMAGE_COUNT=0 ZINK_DESCRIPTORS=lazy ZINK_DEBUG=compact,deck_emu MESA_SHADER_CACHE_DISABLE=false MESA_SHADER_CACHE_MAX_SIZE=512MB mesa_glthread=true WINEESYNC=1 MESA_VK_WSI_PRESENT_MODE=mailbox TU_DEBUG=noconform,deck_emu DXVK_FRAME_RATE=60 VKD3D_SHADER_MODEL=6_0 PULSE_LATENCY_MSEC=144";
public static final String DEFAULT_SCREEN_SIZE = "1280x720";
public static final String DEFAULT_GRAPHICS_DRIVER = DefaultVersion.DEFAULT_GRAPHICS_DRIVER;
public static final String DEFAULT_AUDIO_DRIVER = "pulseaudio";
diff --git a/app/src/main/java/com/winlator/core/envvars/EnvVarInfo.kt b/app/src/main/java/com/winlator/core/envvars/EnvVarInfo.kt
index 0ddb21a848..b6d128d589 100644
--- a/app/src/main/java/com/winlator/core/envvars/EnvVarInfo.kt
+++ b/app/src/main/java/com/winlator/core/envvars/EnvVarInfo.kt
@@ -213,10 +213,7 @@ data class EnvVarInfo(
identifier = "MESA_EXTENSION_MAX_YEAR",
),
"WRAPPER_MAX_IMAGE_COUNT" to EnvVarInfo(
- identifier = "MESA_EXTENSION_MAX_YEAR",
- ),
- "MESA_GL_VERSION_OVERRIDE" to EnvVarInfo(
- identifier = "MESA_EXTENSION_MAX_YEAR",
+ identifier = "WRAPPER_MAX_IMAGE_COUNT",
),
"PULSE_LATENCY_MSEC" to EnvVarInfo(
identifier = "PULSE_LATENCY_MSEC",
@@ -228,6 +225,9 @@ data class EnvVarInfo(
"DXVK_FRAME_RATE" to EnvVarInfo(
identifier = "DXVK_FRAME_RATE",
),
+ "VKD3D_SHADER_MODEL" to EnvVarInfo(
+ identifier = "VKD3D_SHADER_MODEL",
+ ),
"WINE_DO_NOT_CREATE_DXGI_DEVICE_MANAGER" to EnvVarInfo(
identifier = "WINE_DO_NOT_CREATE_DXGI_DEVICE_MANAGER",
selectionType = EnvVarSelectionType.TOGGLE,
@@ -243,6 +243,55 @@ data class EnvVarInfo(
selectionType = EnvVarSelectionType.MULTI_SELECT,
possibleValues = listOf("simple", "fps", "frametime"),
),
+ "MESA_GL_VERSION_OVERRIDE" to EnvVarInfo(
+ identifier = "MESA_GL_VERSION_OVERRIDE",
+ ),
+ "MESA_VK_WSI_DEBUG" to EnvVarInfo(
+ identifier = "MESA_VK_WSI_DEBUG",
+ ),
+ "BOX64_MAX_THREADS" to EnvVarInfo(
+ identifier = "BOX64_MAX_THREADS",
+ ),
+ "VKD3D_FRAME_RATE" to EnvVarInfo(
+ identifier = "VKD3D_FRAME_RATE",
+ ),
+ "VKD3D_THREAD_COUNT" to EnvVarInfo(
+ identifier = "VKD3D_THREAD_COUNT",
+ ),
+ "VKD3D_SHADER_CACHE_PATH" to EnvVarInfo(
+ identifier = "VKD3D_SHADER_CACHE_PATH",
+ ),
+ "DXVK_CONFIG" to EnvVarInfo(
+ identifier = "DXVK_CONFIG",
+ ),
+ "VKD3D_CONFIG" to EnvVarInfo(
+ identifier = "VKD3D_CONFIG",
+ ),
+ "MESA_VK_PRESENT_MODE" to EnvVarInfo(
+ identifier = "MESA_VK_PRESENT_MODE",
+ ),
+ "DXVK_FILTER_DEVICE_NAME" to EnvVarInfo(
+ identifier = "DXVK_FILTER_DEVICE_NAME",
+ selectionType = EnvVarSelectionType.MULTI_SELECT,
+ possibleValues = listOf(
+ "NVIDIA GeForce GTX 1080",
+ "NVIDIA GeForce RTX 3060",
+ "AMD Radeon RX 580",
+ "Radeon HD 7900 Series",
+ ),
+ ),
+ // Wine DLL overrides — user types freely or picks a common preset
+ // More common DLL overrides can be added in future. Only audio related for now
+ "WINEDLLOVERRIDES" to EnvVarInfo(
+ identifier = "WINEDLLOVERRIDES",
+ selectionType = EnvVarSelectionType.SUGGESTIONS,
+ possibleValues = listOf(
+ "openal32=native,builtin",
+ "soft_oal=native",
+ "openal32=native,builtin;soft_oal=native",
+ "xaudio2_7=native,builtin",
+ ),
+ ),
)
}
}
diff --git a/app/src/main/java/com/winlator/core/envvars/EnvVarSelectionType.kt b/app/src/main/java/com/winlator/core/envvars/EnvVarSelectionType.kt
index c961e97915..6edfa8105b 100644
--- a/app/src/main/java/com/winlator/core/envvars/EnvVarSelectionType.kt
+++ b/app/src/main/java/com/winlator/core/envvars/EnvVarSelectionType.kt
@@ -3,5 +3,6 @@ package com.winlator.core.envvars
enum class EnvVarSelectionType {
TOGGLE,
MULTI_SELECT,
+ SUGGESTIONS,
NONE,
}
diff --git a/app/src/main/java/com/winlator/widget/InputControlsView.java b/app/src/main/java/com/winlator/widget/InputControlsView.java
index fdba53260b..5466b31c20 100644
--- a/app/src/main/java/com/winlator/widget/InputControlsView.java
+++ b/app/src/main/java/com/winlator/widget/InputControlsView.java
@@ -848,6 +848,7 @@ public boolean onTouchEvent(MotionEvent event) {
}
touchpadView.setPointerButtonLeftEnabled(true);
+ touchpadView.setPointerButtonRightEnabled(true);
for (ControlElement element : profile.getElements()) {
if (element.handleTouchDown(pointerId, x, y)) {
performHapticFeedback(android.view.HapticFeedbackConstants.VIRTUAL_KEY);
@@ -856,6 +857,9 @@ public boolean onTouchEvent(MotionEvent event) {
if (element.getBindingAt(0) == Binding.MOUSE_LEFT_BUTTON) {
touchpadView.setPointerButtonLeftEnabled(false);
}
+ if (element.getBindingAt(0) == Binding.MOUSE_RIGHT_BUTTON) {
+ touchpadView.setPointerButtonRightEnabled(false);
+ }
}
if (!handled) touchpadView.onTouchEvent(event);
break;
diff --git a/app/src/main/java/com/winlator/widget/TouchpadView.java b/app/src/main/java/com/winlator/widget/TouchpadView.java
index 988072c67c..e860a09f11 100644
--- a/app/src/main/java/com/winlator/widget/TouchpadView.java
+++ b/app/src/main/java/com/winlator/widget/TouchpadView.java
@@ -63,6 +63,9 @@ public class TouchpadView extends View implements View.OnCapturedPointerListener
private final boolean capturePointerOnExternalMouse;
private boolean pointerCaptureRequested;
+ // Suppress spurious left-click after two-finger right-click tap
+ private boolean suppressNextLeftTap;
+
// ── Gesture configuration ────────────────────────────────────────
private TouchGestureConfig gestureConfig = new TouchGestureConfig();
@@ -89,6 +92,7 @@ public class TouchpadView extends View implements View.OnCapturedPointerListener
private float pinchLastDistance;
// Two-finger tap detection
private boolean twoFingerTapPossible;
+ private boolean twoFingerTapFired;
// Track which WASD/arrow keys are currently held
private boolean panKeyUp, panKeyDown, panKeyLeft, panKeyRight;
// True when finger has moved beyond tap tolerance (prevents spurious tap on lift)
@@ -314,6 +318,7 @@ private boolean handleTouchpadEvent(MotionEvent event) {
if (event.isFromSource(InputDevice.SOURCE_MOUSE)) return true;
scrollAccumY = 0;
scrolling = false;
+ suppressNextLeftTap = false;
fingers[pointerId] = new Finger(event.getX(actionIndex), event.getY(actionIndex));
numFingers++;
if (simTouchScreen) {
@@ -545,6 +550,7 @@ private void handleTsDown(MotionEvent event) {
movedBeyondTapThreshold = false;
twoFingerDragging = false;
twoFingerTapPossible = false;
+ twoFingerTapFired = false;
twoFingerMiddleButtonDown = false;
twoFingerGestureMode = TWO_FINGER_GESTURE_NONE;
@@ -712,6 +718,7 @@ private void handleTsPointerUp(MotionEvent event) {
moveCursorTo((int) pt[0], (int) pt[1]);
injectClick(gestureConfig.getTwoFingerTapAction());
injectRelease(gestureConfig.getTwoFingerTapAction());
+ twoFingerTapFired = true;
}
releasePanKeys();
releaseTwoFingerMiddleButton();
@@ -754,6 +761,12 @@ private void handleTsUp(MotionEvent event) {
return;
}
+ // Two-finger tap already sent its click on POINTER_UP — skip the tap on UP
+ if (twoFingerTapFired) {
+ twoFingerTapFired = false;
+ return;
+ }
+
// Simple tap — only if finger stayed within tap tolerance
if (gestureConfig.getTapEnabled() && !movedBeyondTapThreshold) {
moveCursorTo((int) pt[0], (int) pt[1]);
@@ -963,18 +976,20 @@ private void releasePanKeys() {
private void handleFingerUp(Finger finger1) {
switch (this.numFingers) {
case 1:
- if (finger1.isTap()) {
+ if (finger1.isTap() && !suppressNextLeftTap) {
if (this.moveCursorToTouchpoint) {
this.xServer.injectPointerMove(finger1.x, finger1.y);
}
pressPointerButtonLeft(finger1);
break;
}
+ suppressNextLeftTap = false;
break;
case 2:
Finger finger2 = findSecondFinger(finger1);
if (finger2 != null && finger1.isTap()) {
pressPointerButtonRight(finger1);
+ suppressNextLeftTap = true;
break;
}
break;
@@ -1064,10 +1079,11 @@ private void pressPointerButtonLeft(Finger finger) {
}
Pointer pointer = this.xServer.pointer;
Pointer.Button button = Pointer.Button.BUTTON_LEFT;
- if (!pointer.isButtonPressed(button)) {
- this.xServer.injectPointerButtonPress(button);
- this.fingerPointerButtonLeft = finger;
+ if (pointer.isButtonPressed(button)) {
+ this.xServer.injectPointerButtonRelease(button);
}
+ this.xServer.injectPointerButtonPress(button);
+ this.fingerPointerButtonLeft = finger;
}
}
@@ -1081,42 +1097,51 @@ private void pressPointerButtonRight(Finger finger) {
}
Pointer pointer = this.xServer.pointer;
Pointer.Button button = Pointer.Button.BUTTON_RIGHT;
- if (!pointer.isButtonPressed(button)) {
- this.xServer.injectPointerButtonPress(button);
- this.fingerPointerButtonRight = finger;
+ if (pointer.isButtonPressed(button)) {
+ this.xServer.injectPointerButtonRelease(button);
}
+ this.xServer.injectPointerButtonPress(button);
+ this.fingerPointerButtonRight = finger;
}
}
private void releasePointerButtonLeft(Finger finger) {
+ if (!isEnabled() || !this.pointerButtonLeftEnabled || finger != this.fingerPointerButtonLeft) return;
+ final Finger capturedFinger = this.fingerPointerButtonLeft;
// Relative mouse movement support
- if (isEnabled() && this.pointerButtonLeftEnabled && finger == this.fingerPointerButtonLeft && this.xServer.isRelativeMouseMovement()) {
+ if (this.xServer.isRelativeMouseMovement()) {
postDelayed(() -> {
+ if (fingerPointerButtonLeft != capturedFinger) return;
xServer.getWinHandler().mouseEvent(MouseEventFlags.LEFTUP, 0, 0, 0);
fingerPointerButtonLeft = null;
}, 30);
- return;
- }
- if (isEnabled() && this.pointerButtonLeftEnabled && finger == this.fingerPointerButtonLeft && this.xServer.pointer.isButtonPressed(Pointer.Button.BUTTON_LEFT)) {
+ } else {
postDelayed(() -> {
- xServer.injectPointerButtonRelease(Pointer.Button.BUTTON_LEFT);
+ if (fingerPointerButtonLeft != capturedFinger) return;
+ if (xServer.pointer.isButtonPressed(Pointer.Button.BUTTON_LEFT)) {
+ xServer.injectPointerButtonRelease(Pointer.Button.BUTTON_LEFT);
+ }
fingerPointerButtonLeft = null;
}, 30);
}
}
private void releasePointerButtonRight(Finger finger) {
+ if (!isEnabled() || !this.pointerButtonRightEnabled || finger != this.fingerPointerButtonRight) return;
+ final Finger capturedFinger = this.fingerPointerButtonRight;
// Relative mouse movement support
- if (isEnabled() && this.pointerButtonRightEnabled && finger == this.fingerPointerButtonRight && this.xServer.isRelativeMouseMovement()) {
+ if (this.xServer.isRelativeMouseMovement()) {
postDelayed(() -> {
+ if (fingerPointerButtonRight != capturedFinger) return;
xServer.getWinHandler().mouseEvent(MouseEventFlags.RIGHTUP, 0, 0, 0);
fingerPointerButtonRight = null;
}, 30);
- return;
- }
- if (isEnabled() && this.pointerButtonRightEnabled && finger == this.fingerPointerButtonRight && this.xServer.pointer.isButtonPressed(Pointer.Button.BUTTON_RIGHT)) {
+ } else {
postDelayed(() -> {
- xServer.injectPointerButtonRelease(Pointer.Button.BUTTON_RIGHT);
+ if (fingerPointerButtonRight != capturedFinger) return;
+ if (xServer.pointer.isButtonPressed(Pointer.Button.BUTTON_RIGHT)) {
+ xServer.injectPointerButtonRelease(Pointer.Button.BUTTON_RIGHT);
+ }
fingerPointerButtonRight = null;
}, 30);
}
diff --git a/app/src/main/java/com/winlator/xserver/Keyboard.java b/app/src/main/java/com/winlator/xserver/Keyboard.java
index 4b234742ff..2931a40958 100644
--- a/app/src/main/java/com/winlator/xserver/Keyboard.java
+++ b/app/src/main/java/com/winlator/xserver/Keyboard.java
@@ -89,6 +89,28 @@ public static boolean isKeyboardDevice(InputDevice device) {
device.getKeyboardType() == InputDevice.KEYBOARD_TYPE_ALPHABETIC;
}
+ public boolean onVirtualKeyEvent(KeyEvent event) {
+ int action = event.getAction();
+ if (action == KeyEvent.ACTION_DOWN || action == KeyEvent.ACTION_UP) {
+ int keyCode = event.getKeyCode();
+ if (keyCode >= keycodeMap.length) return false;
+
+ XKeycode xKeycode = keycodeMap[keyCode];
+ if (xKeycode == null) return false;
+
+ if (action == KeyEvent.ACTION_DOWN) {
+ boolean shiftPressed = event.isShiftPressed() || keyCode == KeyEvent.KEYCODE_AT || keyCode == KeyEvent.KEYCODE_STAR || keyCode == KeyEvent.KEYCODE_POUND || keyCode == KeyEvent.KEYCODE_PLUS;
+ if (shiftPressed) xServer.injectKeyPress(XKeycode.KEY_SHIFT_L);
+ xServer.injectKeyPress(xKeycode, xKeycode != XKeycode.KEY_ENTER ? event.getUnicodeChar() : 0);
+ }
+ else if (action == KeyEvent.ACTION_UP) {
+ xServer.injectKeyRelease(XKeycode.KEY_SHIFT_L);
+ xServer.injectKeyRelease(xKeycode);
+ }
+ }
+ return true;
+ }
+
public boolean onKeyEvent(KeyEvent event) {
int action = event.getAction();
diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml
index 0e9343810b..c5339720c8 100644
--- a/app/src/main/res/values/arrays.xml
+++ b/app/src/main/res/values/arrays.xml
@@ -69,6 +69,7 @@
- ALSA
- PulseAudio
+ - Disabled
- Keyboard
diff --git a/app/src/test/java/app/gamenative/service/DepotFilteringTest.kt b/app/src/test/java/app/gamenative/service/DepotFilteringTest.kt
index 903d2483ab..f290e75528 100644
--- a/app/src/test/java/app/gamenative/service/DepotFilteringTest.kt
+++ b/app/src/test/java/app/gamenative/service/DepotFilteringTest.kt
@@ -19,6 +19,7 @@ class DepotFilteringTest {
osArch: OSArch = OSArch.Arch64,
dlcAppId: Int = SteamService.INVALID_APP_ID,
language: String = "",
+ systemDefined: Boolean = false,
steamDeck: Boolean = false,
) = DepotInfo(
depotId = depotId,
@@ -30,6 +31,7 @@ class DepotFilteringTest {
manifests = manifests,
encryptedManifests = encryptedManifests,
language = language,
+ systemDefined = systemDefined,
steamDeck = steamDeck,
)
@@ -119,6 +121,18 @@ class DepotFilteringTest {
assertTrue(SteamService.filterForDownloadableDepots(d, true, false, "english", null, null))
}
+ @Test
+ fun `systemDefined depot bypasses license check`() {
+ val d = depot(depotId = 551, manifests = mapOf("public" to manifest()), systemDefined = true)
+ assertTrue(SteamService.filterForDownloadableDepots(d, true, false, "english", null, setOf(552, 553)))
+ }
+
+ @Test
+ fun `non-systemDefined depot still rejected when unlicensed`() {
+ val d = depot(depotId = 100, manifests = mapOf("public" to manifest()), systemDefined = false)
+ assertFalse(SteamService.filterForDownloadableDepots(d, true, false, "english", null, setOf(200, 300)))
+ }
+
// -- Steam Deck depot filtering --
@Test
diff --git a/app/src/test/java/app/gamenative/service/gog/GOGDownloadManagerTest.kt b/app/src/test/java/app/gamenative/service/gog/GOGDownloadManagerTest.kt
new file mode 100644
index 0000000000..f88534e1ed
--- /dev/null
+++ b/app/src/test/java/app/gamenative/service/gog/GOGDownloadManagerTest.kt
@@ -0,0 +1,274 @@
+package app.gamenative.service.gog
+
+import android.content.Context
+import app.gamenative.data.DownloadInfo
+import app.gamenative.data.GOGGame
+import app.gamenative.service.gog.api.BuildsResponse
+import app.gamenative.service.gog.api.Depot
+import app.gamenative.service.gog.api.DepotFile
+import app.gamenative.service.gog.api.DepotManifest
+import app.gamenative.service.gog.api.GOGApiClient
+import app.gamenative.service.gog.api.GOGBuild
+import app.gamenative.service.gog.api.GOGManifestMeta
+import app.gamenative.service.gog.api.GOGManifestParser
+import app.gamenative.service.gog.api.Product
+import app.gamenative.service.gog.api.SecureLinksResponse
+import app.gamenative.service.gog.api.V1DepotFile
+import java.io.File
+import java.nio.file.Files
+import java.util.concurrent.CopyOnWriteArrayList
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.any
+import org.mockito.kotlin.atLeastOnce
+import org.mockito.kotlin.argumentCaptor
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [28], manifest = Config.NONE, application = android.app.Application::class)
+class GOGDownloadManagerTest {
+ private lateinit var apiClient: GOGApiClient
+ private lateinit var parser: GOGManifestParser
+ private lateinit var gogManager: GOGManager
+ private lateinit var context: Context
+ private lateinit var manager: GOGDownloadManager
+
+ @Before
+ fun setUp() {
+ apiClient = mock()
+ parser = mock()
+ gogManager = mock()
+ context = mock()
+ manager = GOGDownloadManager(apiClient, parser, gogManager, context)
+ }
+
+ // ===== Gen 2 =====
+
+ @Test
+ fun gen2_download_includes_game_and_support_files() = runTest {
+ val gameId = "12345"
+ val installPath = Files.createTempDirectory("gog-gen2-install").toFile()
+ val downloadInfo = DownloadInfo(
+ jobCount = 1,
+ gameId = gameId.toInt(),
+ downloadingAppIds = CopyOnWriteArrayList(),
+ )
+
+ val selectedBuild = GOGBuild(
+ buildId = "build-1",
+ productId = gameId,
+ platform = "windows",
+ generation = 2,
+ versionName = "1.0.0",
+ branch = "master",
+ link = "https://manifest.test",
+ legacyBuildId = null,
+ )
+
+ val depot = Depot(
+ productId = gameId,
+ languages = listOf("en-US"),
+ manifest = "depot-manifest",
+ compressedSize = 0L,
+ size = 0L,
+ osBitness = emptyList(),
+ )
+
+ val gameFile = depotFile(path = "bin/game.exe", support = false)
+ val supportFile = depotFile(path = "app/support/vcredist.exe", support = true)
+
+ val manifest = GOGManifestMeta(
+ baseProductId = gameId,
+ installDirectory = "Game",
+ depots = listOf(depot),
+ dependencies = emptyList(),
+ products = listOf(Product(productId = gameId, name = "Game")),
+ productTimestamp = null,
+ scriptInterpreter = false,
+ )
+
+ whenever(gogManager.getGameFromDbById(gameId)).thenReturn(
+ GOGGame(id = gameId, title = "Test Game"),
+ GOGGame(id = gameId, title = "Test Game"),
+ )
+ whenever(gogManager.getAllGameIds()).thenReturn(setOf(gameId))
+ whenever(apiClient.getBuildsForGame(gameId, "windows", 2)).thenReturn(
+ Result.success(BuildsResponse(totalCount = 1, count = 1, items = listOf(selectedBuild))),
+ )
+ whenever(parser.selectBuild(any(), eq(2), eq("windows"))).thenReturn(selectedBuild)
+ whenever(apiClient.fetchManifest(selectedBuild.link)).thenReturn(Result.success(manifest))
+ whenever(parser.filterDepotsByLanguage(manifest, "english")).thenReturn(listOf(depot) to "en-US")
+ whenever(parser.filterDepotsByOwnership(listOf(depot), setOf(gameId))).thenReturn(listOf(depot))
+ whenever(apiClient.fetchDepotManifest(depot.manifest)).thenReturn(
+ Result.success(DepotManifest(files = listOf(gameFile, supportFile), directories = emptyList(), links = emptyList())),
+ )
+ whenever(parser.separateBaseDLC(listOf(gameFile, supportFile), gameId)).thenReturn(
+ listOf(gameFile, supportFile) to emptyList(),
+ )
+ whenever(parser.separateSupportFiles(listOf(gameFile, supportFile))).thenReturn(
+ listOf(gameFile) to listOf(supportFile),
+ )
+ whenever(parser.calculateTotalSize(any())).thenReturn(0L)
+ whenever(parser.extractChunkHashes(any())).thenReturn(emptyList())
+ whenever(parser.buildChunkUrlMapWithProducts(any(), any(), any())).thenReturn(emptyMap())
+
+ val result = manager.downloadGame(
+ gameId = gameId,
+ installPath = installPath,
+ downloadInfo = downloadInfo,
+ language = "english",
+ withDlcs = false,
+ supportDir = null,
+ )
+
+ assertTrue(result.isSuccess)
+
+ val extractedHashesCaptor = argumentCaptor>()
+ verify(parser).extractChunkHashes(extractedHashesCaptor.capture())
+ val hashedPaths = extractedHashesCaptor.firstValue.map { it.path }
+ assertTrue(hashedPaths.contains("bin/game.exe"))
+ assertTrue(hashedPaths.contains("app/support/vcredist.exe"))
+
+ val totalSizeCaptor = argumentCaptor>()
+ verify(parser, atLeastOnce()).calculateTotalSize(totalSizeCaptor.capture())
+ val capturedSizeInputs = totalSizeCaptor.allValues.map { files -> files.map { it.path } }
+ assertTrue(capturedSizeInputs.any { it.contains("bin/game.exe") && it.contains("app/support/vcredist.exe") })
+
+ assertTrue(File(installPath, "bin/game.exe").exists())
+ assertTrue(File(installPath, "support/vcredist.exe").exists())
+
+ installPath.deleteRecursively()
+ }
+
+ // ===== Gen 1 =====
+
+ @Test
+ fun gen1_download_includes_game_and_support_files() = runTest {
+ val gameId = "12345"
+ val installPath = Files.createTempDirectory("gog-gen1-install").toFile()
+ val supportDir = Files.createTempDirectory("gog-gen1-support").toFile()
+ val downloadInfo = DownloadInfo(
+ jobCount = 1,
+ gameId = gameId.toInt(),
+ downloadingAppIds = CopyOnWriteArrayList(),
+ )
+
+ val gen1Build = GOGBuild(
+ buildId = "legacy-build",
+ productId = gameId,
+ platform = "windows",
+ generation = 1,
+ versionName = "1.0.0",
+ branch = "master",
+ link = "https://manifest.test",
+ legacyBuildId = null,
+ )
+
+ val depot = Depot(
+ productId = gameId,
+ languages = listOf("en-US"),
+ manifest = "legacy-manifest",
+ compressedSize = 0L,
+ size = 0L,
+ osBitness = emptyList(),
+ )
+
+ val manifest = GOGManifestMeta(
+ baseProductId = gameId,
+ installDirectory = "Game",
+ depots = listOf(depot),
+ dependencies = emptyList(),
+ products = listOf(Product(productId = gameId, name = "Game")),
+ productTimestamp = "111111",
+ scriptInterpreter = false,
+ )
+
+ val gameV1File = V1DepotFile(
+ path = "bin/game.exe",
+ size = 0L,
+ hash = "",
+ url = null,
+ offset = null,
+ isSupport = false,
+ )
+ val supportV1File = V1DepotFile(
+ path = "__redist/vcredist.exe",
+ size = 0L,
+ hash = "",
+ url = null,
+ offset = null,
+ isSupport = true,
+ )
+
+ whenever(gogManager.getGameFromDbById(gameId)).thenReturn(
+ GOGGame(id = gameId, title = "Test Game"),
+ GOGGame(id = gameId, title = "Test Game"),
+ )
+ whenever(gogManager.getAllGameIds()).thenReturn(setOf(gameId))
+ whenever(apiClient.getBuildsForGame(gameId, "windows", 2)).thenReturn(
+ Result.success(BuildsResponse(totalCount = 0, count = 0, items = emptyList())),
+ )
+ whenever(apiClient.getBuildsForGame(gameId, "windows", 1)).thenReturn(
+ Result.success(BuildsResponse(totalCount = 1, count = 1, items = listOf(gen1Build))),
+ )
+ whenever(parser.selectBuild(any(), eq(2), eq("windows"))).thenReturn(null)
+ whenever(parser.selectBuild(any(), eq(1), eq("windows"))).thenReturn(gen1Build)
+ whenever(apiClient.fetchManifest(gen1Build.link)).thenReturn(Result.success(manifest))
+ whenever(parser.filterDepotsByLanguage(manifest, "english")).thenReturn(listOf(depot) to "en-US")
+ whenever(parser.filterDepotsByOwnership(listOf(depot), setOf(gameId))).thenReturn(listOf(depot))
+ whenever(
+ apiClient.getSecureLink(
+ productId = gameId,
+ path = "/windows/111111/",
+ generation = 1,
+ ),
+ ).thenReturn(Result.success(SecureLinksResponse(urls = listOf("https://cdn.example.com"))))
+ whenever(
+ apiClient.fetchDepotManifestV1(
+ productId = gameId,
+ platform = "windows",
+ timestamp = "111111",
+ manifestHash = "legacy-manifest",
+ ),
+ ).thenReturn(Result.success("{}"))
+ whenever(parser.parseV1DepotManifest("{}")).thenReturn(listOf(gameV1File, supportV1File))
+
+ val result = manager.downloadGame(
+ gameId = gameId,
+ installPath = installPath,
+ downloadInfo = downloadInfo,
+ language = "english",
+ withDlcs = false,
+ supportDir = supportDir,
+ )
+
+ assertTrue(result.isSuccess)
+ assertTrue(File(installPath, "bin/game.exe").exists())
+ assertTrue(File(supportDir, "__redist/vcredist.exe").exists())
+
+ installPath.deleteRecursively()
+ supportDir.deleteRecursively()
+ }
+
+ private fun depotFile(path: String, support: Boolean): DepotFile {
+ val flags = if (support) listOf("support") else emptyList()
+ return DepotFile(
+ path = path,
+ chunks = emptyList(),
+ md5 = null,
+ sha256 = null,
+ flags = flags,
+ productId = null,
+ )
+ }
+}
diff --git a/tools/convert-wcp-to-tzst.sh b/tools/convert-wcp-to-tzst.sh
new file mode 100755
index 0000000000..470b84347b
--- /dev/null
+++ b/tools/convert-wcp-to-tzst.sh
@@ -0,0 +1,53 @@
+#!/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" \
+ system32/libwow64fex.dll \
+ system32/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"