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"