From 33a0e219fc70e22c558912764dd7336b05c96875 Mon Sep 17 00:00:00 2001 From: AMIAY Date: Fri, 30 Jan 2026 04:11:10 +0100 Subject: [PATCH] Add differential update system --- backend/core/testConfig.js | 7 + backend/managers/differentialUpdateManager.js | 272 ++++++++++++++++++ backend/managers/gameLauncher.js | 11 +- backend/managers/gameManager.js | 19 +- backend/services/versionManager.js | 142 ++++++++- 5 files changed, 437 insertions(+), 14 deletions(-) create mode 100644 backend/core/testConfig.js create mode 100644 backend/managers/differentialUpdateManager.js diff --git a/backend/core/testConfig.js b/backend/core/testConfig.js new file mode 100644 index 0000000..e6e9687 --- /dev/null +++ b/backend/core/testConfig.js @@ -0,0 +1,7 @@ +const FORCE_CLEAN_INSTALL_VERSION = false; +const CLEAN_INSTALL_TEST_VERSION = '4.pwr'; + +module.exports = { + FORCE_CLEAN_INSTALL_VERSION, + CLEAN_INSTALL_TEST_VERSION +}; diff --git a/backend/managers/differentialUpdateManager.js b/backend/managers/differentialUpdateManager.js new file mode 100644 index 0000000..5df790f --- /dev/null +++ b/backend/managers/differentialUpdateManager.js @@ -0,0 +1,272 @@ +const fs = require('fs'); +const path = require('path'); +const { execFile } = require('child_process'); +const { downloadFile, retryDownload } = require('../utils/fileManager'); +const { getOS, getArch } = require('../utils/platformUtils'); +const { validateChecksum, extractVersionDetails, canUseDifferentialUpdate, needsIntermediatePatches, getInstalledClientVersion } = require('../services/versionManager'); +const { installButler } = require('./butlerManager'); +const { GAME_DIR, CACHE_DIR, TOOLS_DIR } = require('../core/paths'); +const { saveVersionClient } = require('../core/config'); + +async function acquireGameArchive(downloadUrl, targetPath, checksum, progressCallback, allowRetry = true) { + const osName = getOS(); + const arch = getArch(); + + if (osName === 'darwin' && arch === 'amd64') { + throw new Error('Hytale x86_64 Intel Mac Support has not been released yet. Please check back later.'); + } + + if (fs.existsSync(targetPath)) { + const stats = fs.statSync(targetPath); + if (stats.size > 1024 * 1024) { + const isValid = await validateChecksum(targetPath, checksum); + if (isValid) { + console.log(`Valid archive found in cache: ${targetPath}`); + return targetPath; + } + console.log('Cached archive checksum mismatch, re-downloading'); + fs.unlinkSync(targetPath); + } + } + + console.log(`Downloading game archive from: ${downloadUrl}`); + + try { + if (allowRetry) { + await retryDownload(downloadUrl, targetPath, progressCallback); + } else { + await downloadFile(downloadUrl, targetPath, progressCallback); + } + } catch (error) { + const enhancedError = new Error(`Archive download failed: ${error.message}`); + enhancedError.originalError = error; + enhancedError.downloadUrl = downloadUrl; + enhancedError.targetPath = targetPath; + throw enhancedError; + } + + const stats = fs.statSync(targetPath); + console.log(`Archive downloaded, size: ${(stats.size / 1024 / 1024).toFixed(2)} MB`); + + const isValid = await validateChecksum(targetPath, checksum); + if (!isValid) { + console.log('Downloaded archive checksum validation failed, removing corrupted file'); + fs.unlinkSync(targetPath); + throw new Error('Downloaded archive is corrupted or invalid. Please retry'); + } + + console.log(`Archive validation passed: ${targetPath}`); + return targetPath; +} + +async function deployGameArchive(archivePath, destinationDir, toolsDir, progressCallback, isDifferential = false) { + if (!archivePath || !fs.existsSync(archivePath)) { + throw new Error(`Archive not found: ${archivePath || 'undefined'}`); + } + + const stats = fs.statSync(archivePath); + console.log(`Deploying archive: ${archivePath}`); + console.log(`Archive size: ${(stats.size / 1024 / 1024).toFixed(2)} MB`); + console.log(`Deployment mode: ${isDifferential ? 'differential' : 'full'}`); + + const butlerPath = await installButler(toolsDir); + const stagingDir = path.join(destinationDir, 'staging-temp'); + + if (!fs.existsSync(destinationDir)) { + fs.mkdirSync(destinationDir, { recursive: true }); + } + + if (fs.existsSync(stagingDir)) { + fs.rmSync(stagingDir, { recursive: true, force: true }); + } + fs.mkdirSync(stagingDir, { recursive: true }); + + if (progressCallback) { + progressCallback(isDifferential ? 'Applying differential update...' : 'Installing game files...', null, null, null, null); + } + + const args = [ + 'apply', + '--staging-dir', + stagingDir, + archivePath, + destinationDir + ]; + + console.log(`Executing deployment: ${butlerPath} ${args.join(' ')}`); + + return new Promise((resolve, reject) => { + const child = execFile(butlerPath, args, { + maxBuffer: 1024 * 1024 * 10, + timeout: 600000 + }, (error, stdout, stderr) => { + if (error) { + const cleanStderr = stderr.replace(/[\u2714\u2716\u2713\u2717\u26A0\uD83D[\uDC00-\uDFFF]]/g, '').trim(); + const cleanStdout = stdout.replace(/[\u2714\u2716\u2713\u2717\u26A0\uD83D[\uDC00-\uDFFF]]/g, '').trim(); + + if (cleanStderr) console.error('Deployment stderr:', cleanStderr); + if (cleanStdout) console.error('Deployment stdout:', cleanStdout); + + const errorText = (stderr + ' ' + error.message).toLowerCase(); + let message = 'Game deployment failed'; + + if (errorText.includes('unexpected eof')) { + message = 'Corrupted archive detected. Please retry download.'; + if (fs.existsSync(archivePath)) { + fs.unlinkSync(archivePath); + } + } else if (errorText.includes('permission denied')) { + message = 'Permission denied. Check file permissions and try again.'; + } else if (errorText.includes('no space left') || errorText.includes('device full')) { + message = 'Insufficient disk space. Free up space and try again.'; + } + + const deployError = new Error(message); + deployError.originalError = error; + deployError.stderr = cleanStderr; + deployError.stdout = cleanStdout; + return reject(deployError); + } + + console.log('Game deployment completed successfully'); + const cleanOutput = stdout.replace(/[\u2714\u2716\u2713\u2717\u26A0\uD83D[\uDC00-\uDFFF]]/g, '').trim(); + if (cleanOutput) { + console.log(cleanOutput); + } + + if (fs.existsSync(stagingDir)) { + try { + fs.rmSync(stagingDir, { recursive: true, force: true }); + } catch (cleanupErr) { + console.warn('Failed to cleanup staging directory:', cleanupErr.message); + } + } + + resolve(); + }); + + child.on('error', (err) => { + console.error('Deployment process error:', err); + reject(new Error(`Failed to execute deployment tool: ${err.message}`)); + }); + }); +} + +async function performIntelligentUpdate(targetVersion, branch = 'release', progressCallback, gameDir = GAME_DIR, cacheDir = CACHE_DIR, toolsDir = TOOLS_DIR) { + console.log(`Initiating intelligent update to version ${targetVersion}`); + + const currentVersion = getInstalledClientVersion(); + console.log(`Current version: ${currentVersion || 'none (clean install)'}`); + console.log(`Target version: ${targetVersion}`); + console.log(`Branch: ${branch}`); + + if (branch !== 'release') { + console.log(`Pre-release branch detected - forcing full archive download`); + const versionDetails = await extractVersionDetails(targetVersion, branch); + const archiveName = path.basename(versionDetails.fullUrl); + const archivePath = path.join(cacheDir, `${branch}_${archiveName}`); + + if (progressCallback) { + progressCallback('Downloading full game archive (pre-release)...', 0, null, null, null); + } + + await acquireGameArchive(versionDetails.fullUrl, archivePath, null, progressCallback); + await deployGameArchive(archivePath, gameDir, toolsDir, progressCallback, false); + saveVersionClient(targetVersion); + console.log(`Pre-release installation completed. Version ${targetVersion} is now installed.`); + return; + } + + if (!currentVersion) { + console.log('No existing installation detected - downloading full archive'); + const versionDetails = await extractVersionDetails(targetVersion, branch); + const archiveName = path.basename(versionDetails.fullUrl); + const archivePath = path.join(cacheDir, `${branch}_${archiveName}`); + + if (progressCallback) { + progressCallback(`Downloading full game archive (first install - v${targetVersion})...`, 0, null, null, null); + } + + await acquireGameArchive(versionDetails.fullUrl, archivePath, null, progressCallback); + await deployGameArchive(archivePath, gameDir, toolsDir, progressCallback, false); + saveVersionClient(targetVersion); + console.log(`Initial installation completed. Version ${targetVersion} is now installed.`); + return; + } + + const patchesToApply = needsIntermediatePatches(currentVersion, targetVersion); + + if (patchesToApply.length === 0) { + console.log('Already at target version or invalid version sequence'); + return; + } + + console.log(`Applying ${patchesToApply.length} differential patch(es): ${patchesToApply.join(' -> ')}`); + + for (let i = 0; i < patchesToApply.length; i++) { + const patchVersion = patchesToApply[i]; + const versionDetails = await extractVersionDetails(patchVersion, branch); + + const canDifferential = canUseDifferentialUpdate(getInstalledClientVersion(), versionDetails); + + if (!canDifferential || !versionDetails.differentialUrl) { + console.log(`WARNING: Differential patch not available for ${patchVersion}, using full archive`); + const archiveName = path.basename(versionDetails.fullUrl); + const archivePath = path.join(cacheDir, `${branch}_${archiveName}`); + + if (progressCallback) { + progressCallback(`Downloading full archive for ${patchVersion} (${i + 1}/${patchesToApply.length})...`, 0, null, null, null); + } + + await acquireGameArchive(versionDetails.fullUrl, archivePath, null, progressCallback); + await deployGameArchive(archivePath, gameDir, toolsDir, progressCallback, false); + } else { + console.log(`Applying differential patch: ${versionDetails.sourceVersion} -> ${patchVersion}`); + const archiveName = path.basename(versionDetails.differentialUrl); + const archivePath = path.join(cacheDir, `${branch}_patch_${archiveName}`); + + if (progressCallback) { + progressCallback(`Applying patch ${i + 1}/${patchesToApply.length}: ${patchVersion}...`, 0, null, null, null); + } + + await acquireGameArchive(versionDetails.differentialUrl, archivePath, versionDetails.checksum, progressCallback); + await deployGameArchive(archivePath, gameDir, toolsDir, progressCallback, true); + + if (fs.existsSync(archivePath)) { + try { + fs.unlinkSync(archivePath); + console.log(`Cleaned up patch file: ${archiveName}`); + } catch (cleanupErr) { + console.warn(`Failed to cleanup patch file: ${cleanupErr.message}`); + } + } + } + + saveVersionClient(patchVersion); + console.log(`Patch ${patchVersion} applied successfully (${i + 1}/${patchesToApply.length})`); + } + + console.log(`Update completed successfully. Version ${targetVersion} is now installed.`); +} + +async function ensureGameInstalled(targetVersion, branch = 'release', progressCallback, gameDir = GAME_DIR, cacheDir = CACHE_DIR, toolsDir = TOOLS_DIR) { + const { findClientPath } = require('../core/paths'); + const clientPath = findClientPath(gameDir); + + if (clientPath) { + const currentVersion = getInstalledClientVersion(); + if (currentVersion === targetVersion) { + console.log(`Game already installed at correct version: ${targetVersion}`); + return; + } + } + + await performIntelligentUpdate(targetVersion, branch, progressCallback, gameDir, cacheDir, toolsDir); +} + +module.exports = { + acquireGameArchive, + deployGameArchive, + performIntelligentUpdate, + ensureGameInstalled +}; diff --git a/backend/managers/gameLauncher.js b/backend/managers/gameLauncher.js index 4555844..a1c43a4 100644 --- a/backend/managers/gameLauncher.js +++ b/backend/managers/gameLauncher.js @@ -10,7 +10,8 @@ const { setupWaylandEnvironment, setupGpuEnvironment } = require('../utils/platf const { saveUsername, saveInstallPath, loadJavaPath, getUuidForUser, getAuthServerUrl, getAuthDomain, loadVersionBranch, loadVersionClient, saveVersionClient } = require('../core/config'); const { resolveJavaPath, getJavaExec, getBundledJavaPath, detectSystemJava, JAVA_EXECUTABLE } = require('./javaManager'); const { getLatestClientVersion } = require('../services/versionManager'); -const { updateGameFiles } = require('./gameManager'); +const { FORCE_CLEAN_INSTALL_VERSION, CLEAN_INSTALL_TEST_VERSION } = require('../core/testConfig'); +const { ensureGameInstalled } = require('./differentialUpdateManager'); const { syncModsForCurrentProfile } = require('./modManager'); const { getUserDataPath } = require('../utils/userDataMigration'); const { syncServerList } = require('../utils/serverListSync'); @@ -446,7 +447,13 @@ async function launchGameWithVersionCheck(playerName = 'Player', progressCallbac const customCacheDir = path.join(customAppDir, 'cache'); try { - await updateGameFiles(latestVersion, progressCallback, customGameDir, customToolsDir, customCacheDir, branch); + let versionToInstall = latestVersion; + if (FORCE_CLEAN_INSTALL_VERSION && !installedVersion) { + versionToInstall = CLEAN_INSTALL_TEST_VERSION; + console.log(`TESTING MODE: Clean install detected, forcing version ${versionToInstall} instead of ${latestVersion}`); + } + + await ensureGameInstalled(versionToInstall, branch, progressCallback, customGameDir, customCacheDir, customToolsDir); console.log('Game updated successfully, patching will be forced on launch...'); if (progressCallback) { diff --git a/backend/managers/gameManager.js b/backend/managers/gameManager.js index 139008d..966a50f 100644 --- a/backend/managers/gameManager.js +++ b/backend/managers/gameManager.js @@ -5,6 +5,7 @@ const { getResolvedAppDir, findClientPath, findUserDataPath, findUserDataRecursi const { getOS, getArch } = require('../utils/platformUtils'); const { downloadFile, retryDownload, retryStalledDownload, MAX_AUTOMATIC_STALL_RETRIES } = require('../utils/fileManager'); const { getLatestClientVersion, getInstalledClientVersion } = require('../services/versionManager'); +const { FORCE_CLEAN_INSTALL_VERSION, CLEAN_INSTALL_TEST_VERSION } = require('../core/testConfig'); const { installButler } = require('./butlerManager'); const { downloadAndReplaceHomePageUI, downloadAndReplaceLogo } = require('./uiFileManager'); const { saveUsername, saveInstallPath, loadJavaPath, CONFIG_FILE, loadConfig, loadVersionBranch, saveVersionClient, loadVersionClient } = require('../core/config'); @@ -528,31 +529,33 @@ async function installGame(playerName = 'Player', progressCallback, javaPathOver console.log(`Installing game files for branch: ${branch}...`); const latestVersion = await getLatestClientVersion(branch); + const targetVersion = FORCE_CLEAN_INSTALL_VERSION ? CLEAN_INSTALL_TEST_VERSION : latestVersion; + + if (FORCE_CLEAN_INSTALL_VERSION) { + console.log(`TESTING MODE: Forcing installation of ${targetVersion} instead of ${latestVersion}`); + } + let pwrFile; try { - pwrFile = await downloadPWR(branch, latestVersion, progressCallback, customCacheDir); + pwrFile = await downloadPWR(branch, targetVersion, progressCallback, customCacheDir); - // If downloadPWR returns false, it means the file doesn't exist or is invalid - // We should retry the download with a manual retry flag if (!pwrFile) { console.log('[Install] PWR file not found or invalid, attempting retry...'); - pwrFile = await retryPWRDownload(branch, latestVersion, progressCallback, customCacheDir); + pwrFile = await retryPWRDownload(branch, targetVersion, progressCallback, customCacheDir); } - // Double-check we have a valid file path if (!pwrFile || typeof pwrFile !== 'string') { throw new Error(`PWR file download failed: received invalid path ${pwrFile}. Please retry download.`); } } catch (downloadError) { console.error('[Install] PWR download failed:', downloadError.message); - throw downloadError; // Re-throw to be handled by the main installGame error handler + throw downloadError; } await applyPWR(pwrFile, progressCallback, customGameDir, customToolsDir, branch, customCacheDir); - // Save the installed version and branch to config - saveVersionClient(latestVersion); + saveVersionClient(targetVersion); const { saveVersionBranch } = require('../core/config'); saveVersionBranch(branch); diff --git a/backend/services/versionManager.js b/backend/services/versionManager.js index 3cf0010..ff4e037 100644 --- a/backend/services/versionManager.js +++ b/backend/services/versionManager.js @@ -1,11 +1,17 @@ const axios = require('axios'); +const crypto = require('crypto'); +const fs = require('fs'); +const { getOS, getArch } = require('../utils/platformUtils'); + +const BASE_PATCH_URL = 'https://game-patches.hytale.com/patches'; +const MANIFEST_API = 'https://files.hytalef2p.com/api/patch_manifest'; async function getLatestClientVersion(branch = 'release') { try { console.log(`Fetching latest client version from API (branch: ${branch})...`); const response = await axios.get('https://files.hytalef2p.com/api/version_client', { params: { branch }, - timeout: 40000, // fixed from 5000 to 40000 to make sure the client trying to connect on the server with slow internet + timeout: 40000, headers: { 'User-Agent': 'Hytale-F2P-Launcher' } @@ -16,16 +22,144 @@ async function getLatestClientVersion(branch = 'release') { console.log(`Latest client version for ${branch}: ${version}`); return version; } else { - console.log('Warning: Invalid API response, falling back to latest known version (7.pwr - 2026-01-29)'); // added latest version fallback and latest known version as per today + console.log('Warning: Invalid API response, falling back to latest known version (7.pwr)'); return '7.pwr'; } } catch (error) { console.error('Error fetching client version:', error.message); - console.log('Warning: API unavailable, falling back to latest known version (7.pwr - 2026-01-29)'); + console.log('Warning: API unavailable, falling back to latest known version (7.pwr)'); return '7.pwr'; } } +function buildArchiveUrl(buildNumber, branch = 'release') { + const os = getOS(); + const arch = getArch(); + return `${BASE_PATCH_URL}/${os}/${arch}/${branch}/0/${buildNumber}.pwr`; +} + +async function checkArchiveExists(buildNumber, branch = 'release') { + const url = buildArchiveUrl(buildNumber, branch); + try { + const response = await axios.head(url, { timeout: 10000 }); + return response.status === 200; + } catch (error) { + return false; + } +} + +async function discoverAvailableVersions(latestKnown, branch = 'release', maxProbe = 50) { + const available = []; + const latest = parseInt(latestKnown.replace('.pwr', '')); + + for (let i = latest; i >= Math.max(1, latest - maxProbe); i--) { + const exists = await checkArchiveExists(i, branch); + if (exists) { + available.push(`${i}.pwr`); + } + } + + return available; +} + +async function fetchPatchManifest(branch = 'release') { + try { + const os = getOS(); + const arch = getArch(); + const response = await axios.get(MANIFEST_API, { + params: { branch, os, arch }, + timeout: 10000 + }); + return response.data.patches || {}; + } catch (error) { + console.error('Failed to fetch patch manifest:', error.message); + return {}; + } +} + +async function extractVersionDetails(targetVersion, branch = 'release') { + const buildNumber = parseInt(targetVersion.replace('.pwr', '')); + const previousBuild = buildNumber - 1; + + const manifest = await fetchPatchManifest(branch); + const patchInfo = manifest[buildNumber]; + + return { + version: targetVersion, + buildNumber: buildNumber, + buildName: `HYTALE-Build-${buildNumber}`, + fullUrl: patchInfo?.original_url || buildArchiveUrl(buildNumber, branch), + differentialUrl: patchInfo?.patch_url || null, + checksum: patchInfo?.patch_hash || null, + sourceVersion: patchInfo?.from ? `${patchInfo.from}.pwr` : (previousBuild > 0 ? `${previousBuild}.pwr` : null), + isDifferential: !!patchInfo?.proper_patch, + releaseNotes: patchInfo?.patch_note || null + }; +} + +function canUseDifferentialUpdate(currentVersion, targetDetails) { + if (!targetDetails) return false; + if (!targetDetails.differentialUrl) return false; + if (!targetDetails.isDifferential) return false; + + if (!currentVersion) return false; + + const currentBuild = parseInt(currentVersion.replace('.pwr', '')); + const expectedSource = parseInt(targetDetails.sourceVersion?.replace('.pwr', '') || '0'); + + return currentBuild === expectedSource; +} + +function needsIntermediatePatches(currentVersion, targetVersion) { + if (!currentVersion) return []; + + const current = parseInt(currentVersion.replace('.pwr', '')); + const target = parseInt(targetVersion.replace('.pwr', '')); + + const intermediates = []; + for (let i = current + 1; i <= target; i++) { + intermediates.push(`${i}.pwr`); + } + + return intermediates; +} + +async function computeFileChecksum(filePath) { + return new Promise((resolve, reject) => { + const hash = crypto.createHash('sha256'); + const stream = fs.createReadStream(filePath); + + stream.on('data', data => hash.update(data)); + stream.on('end', () => resolve(hash.digest('hex'))); + stream.on('error', reject); + }); +} + +async function validateChecksum(filePath, expectedChecksum) { + if (!expectedChecksum) return true; + + const actualChecksum = await computeFileChecksum(filePath); + return actualChecksum === expectedChecksum; +} + +function getInstalledClientVersion() { + try { + const { loadVersionClient } = require('../core/config'); + return loadVersionClient(); + } catch (err) { + return null; + } +} + module.exports = { - getLatestClientVersion + getLatestClientVersion, + buildArchiveUrl, + checkArchiveExists, + discoverAvailableVersions, + extractVersionDetails, + canUseDifferentialUpdate, + needsIntermediatePatches, + computeFileChecksum, + validateChecksum, + getInstalledClientVersion };