From 4c059f0a6b4026ac12ff061e4764c6e40c9c8271 Mon Sep 17 00:00:00 2001 From: sanasol Date: Tue, 27 Jan 2026 05:01:06 +0100 Subject: [PATCH] fix: Steam Deck/Ubuntu crash with jemalloc allocator Root cause: glibc 2.41 has stricter heap validation that catches a pre-existing race condition triggered by binary patching. Changes: - Add jemalloc auto-detection and usage on Linux - Add auto-install via pkexec (graphical sudo prompt) - Clean up clientPatcher.js (remove debug env vars) - Add null-padding fix for shorter domain replacements - Document investigation and solution The launcher now: 1. Auto-detects jemalloc if installed 2. Offers to auto-install if missing (password prompt) 3. Falls back to MALLOC_CHECK_=0 if jemalloc unavailable Install manually: sudo pacman -S jemalloc (Arch/Steam Deck) sudo apt install libjemalloc2 (Debian/Ubuntu) Co-Authored-By: Claude Opus 4.5 --- backend/managers/gameLauncher.js | 104 +++++ backend/utils/clientPatcher.js | 551 +++++--------------------- docs/STEAMDECK_CRASH_INVESTIGATION.md | 201 ++++++++++ docs/STEAMDECK_DEBUG_COMMANDS.md | 95 +++++ 4 files changed, 492 insertions(+), 459 deletions(-) create mode 100644 docs/STEAMDECK_CRASH_INVESTIGATION.md create mode 100644 docs/STEAMDECK_DEBUG_COMMANDS.md diff --git a/backend/managers/gameLauncher.js b/backend/managers/gameLauncher.js index fddfa7f..ce9a7d1 100644 --- a/backend/managers/gameLauncher.js +++ b/backend/managers/gameLauncher.js @@ -24,6 +24,57 @@ try { const execAsync = promisify(exec); +/** + * Try to auto-install jemalloc on Linux using pkexec (graphical sudo) + * Returns true if installation was successful + */ +async function tryInstallJemalloc() { + console.log('Linux: Attempting to auto-install jemalloc...'); + + // Detect package manager and get install command + let installCmd = null; + try { + await execAsync('which pacman'); + installCmd = 'pacman -S --noconfirm jemalloc'; + } catch (e) { + try { + await execAsync('which apt'); + installCmd = 'apt install -y libjemalloc2'; + } catch (e2) { + try { + await execAsync('which dnf'); + installCmd = 'dnf install -y jemalloc'; + } catch (e3) { + console.log('Linux: Could not detect package manager for auto-install'); + return false; + } + } + } + + // Try pkexec first (graphical sudo), fall back to sudo + const sudoCommands = ['pkexec', 'sudo']; + for (const sudoCmd of sudoCommands) { + try { + await execAsync(`which ${sudoCmd}`); + console.log(`Linux: Installing jemalloc with: ${sudoCmd} ${installCmd}`); + await execAsync(`${sudoCmd} ${installCmd}`, { timeout: 120000 }); + console.log('Linux: jemalloc installed successfully'); + return true; + } catch (e) { + if (e.killed) { + console.log('Linux: Install timed out'); + } else if (e.code === 126 || e.code === 127) { + continue; + } else { + console.log(`Linux: Install failed with ${sudoCmd}: ${e.message}`); + } + } + } + + console.log('Linux: Auto-install failed, manual installation required'); + return false; +} + // Fetch tokens from the auth server (properly signed with server's Ed25519 key) async function fetchAuthTokens(uuid, name) { const authServerUrl = getAuthServerUrl(); @@ -285,6 +336,59 @@ exec "$REAL_JAVA" "\${ARGS[@]}" const gpuEnv = setupGpuEnvironment(gpuPreference); Object.assign(env, gpuEnv); + // Linux: Use jemalloc to fix "free(): invalid pointer" crash on glibc 2.41+ (Steam Deck, Ubuntu LTS) + // Root cause: glibc 2.41 has stricter heap validation that catches a pre-existing race condition + if (process.platform === 'linux') { + if (process.env.HYTALE_NO_JEMALLOC !== '1') { + const jemallocPaths = [ + '/usr/lib/libjemalloc.so.2', // Arch Linux, Steam Deck + '/usr/lib/x86_64-linux-gnu/libjemalloc.so.2', // Debian/Ubuntu + '/usr/lib64/libjemalloc.so.2', // Fedora/RHEL + '/usr/lib/libjemalloc.so', // Generic fallback + '/usr/lib/x86_64-linux-gnu/libjemalloc.so', // Debian/Ubuntu fallback + '/usr/lib64/libjemalloc.so' // Fedora/RHEL fallback + ]; + + let jemalloc = null; + for (const p of jemallocPaths) { + if (fs.existsSync(p)) { + jemalloc = p; + break; + } + } + + if (jemalloc) { + env.LD_PRELOAD = jemalloc + (env.LD_PRELOAD ? ':' + env.LD_PRELOAD : ''); + console.log(`Linux: Using jemalloc allocator for stability (${jemalloc})`); + } else { + // Try auto-install + if (process.env.HYTALE_AUTO_INSTALL_JEMALLOC !== '0') { + const installed = await tryInstallJemalloc(); + if (installed) { + for (const p of jemallocPaths) { + if (fs.existsSync(p)) { + jemalloc = p; + break; + } + } + if (jemalloc) { + env.LD_PRELOAD = jemalloc + (env.LD_PRELOAD ? ':' + env.LD_PRELOAD : ''); + console.log(`Linux: Using jemalloc after auto-install (${jemalloc})`); + } + } + } + + if (!jemalloc) { + env.MALLOC_CHECK_ = '0'; + console.log('Linux: jemalloc not found - install with: sudo pacman -S jemalloc (Arch) or sudo apt install libjemalloc2 (Debian/Ubuntu)'); + console.log('Linux: Using fallback MALLOC_CHECK_=0 (may still crash on glibc 2.41+)'); + } + } + } else { + console.log('Linux: jemalloc disabled by HYTALE_NO_JEMALLOC=1'); + } + } + try { let spawnOptions = { stdio: ['ignore', 'pipe', 'pipe'], diff --git a/backend/utils/clientPatcher.js b/backend/utils/clientPatcher.js index 3446fed..5f93a6c 100644 --- a/backend/utils/clientPatcher.js +++ b/backend/utils/clientPatcher.js @@ -65,15 +65,13 @@ class ClientPatcher { */ getDomainStrategy(domain) { if (domain.length <= 10) { - // Direct replacement - subdomains will be stripped return { mode: 'direct', mainDomain: domain, - subdomainPrefix: '', // Empty = subdomains stripped + subdomainPrefix: '', description: `Direct replacement: hytale.com -> ${domain}` }; } else { - // Split mode: first 6 chars become subdomain prefix, rest replaces hytale.com const prefix = domain.slice(0, 6); const suffix = domain.slice(6); return { @@ -88,20 +86,16 @@ class ClientPatcher { /** * Convert a string to the length-prefixed byte format used by the client * Format: [length byte] [00 00 00 padding] [char1] [00] [char2] [00] ... [lastChar] - * Note: No null byte after the last character */ stringToLengthPrefixed(str) { const length = str.length; - const result = Buffer.alloc(4 + length + (length - 1)); // length byte + padding + chars + separators + const result = Buffer.alloc(4 + length + (length - 1)); - // Length byte result[0] = length; - // Padding: 00 00 00 result[1] = 0x00; result[2] = 0x00; result[3] = 0x00; - // Characters with null separators (no separator after last char) let pos = 4; for (let i = 0; i < length; i++) { result[pos++] = str.charCodeAt(i); @@ -147,8 +141,8 @@ class ClientPatcher { } /** - * Replace bytes in buffer - only overwrites the length of new bytes - * Prevents offset corruption by not expanding the replacement + * Replace bytes in buffer with null-padding for shorter replacements + * When new pattern is shorter than old, pads with 0x00 to prevent leftover bytes */ replaceBytes(buffer, oldBytes, newBytes) { let count = 0; @@ -162,7 +156,10 @@ class ClientPatcher { const positions = this.findAllOccurrences(result, oldBytes); for (const pos of positions) { - // Only overwrite the length of the new bytes + // Zero-fill the old region first if new is shorter (prevents leftover bytes) + if (newBytes.length < oldBytes.length) { + result.fill(0x00, pos, pos + oldBytes.length); + } newBytes.copy(result, pos); count++; } @@ -171,8 +168,7 @@ class ClientPatcher { } /** - * UTF-8 domain replacement for Java JAR files. - * Java stores strings in UTF-8 format in the constant pool. + * UTF-8 domain replacement for Java JAR files */ findAndReplaceDomainUtf8(data, oldDomain, newDomain) { let count = 0; @@ -186,22 +182,23 @@ class ClientPatcher { for (const pos of positions) { newUtf8.copy(result, pos); count++; - console.log(` Patched UTF-8 occurrence at offset 0x${pos.toString(16)}`); } return { buffer: result, count }; } /** - * Smart domain replacement that handles both null-terminated and non-null-terminated strings. - * .NET AOT stores some strings in various formats: - * - Standard UTF-16LE (each char is 2 bytes with \x00 high byte) - * - Length-prefixed where last char may have metadata byte instead of \x00 + * Smart domain replacement for .NET AOT binaries */ findAndReplaceDomainSmart(data, oldDomain, newDomain) { let count = 0; const result = Buffer.from(data); + if (newDomain.length > oldDomain.length) { + console.warn(` Warning: New domain longer than old, skipping smart replacement`); + return { buffer: result, count: 0 }; + } + const oldUtf16NoLast = this.stringToUtf16LE(oldDomain.slice(0, -1)); const newUtf16NoLast = this.stringToUtf16LE(newDomain.slice(0, -1)); @@ -217,18 +214,13 @@ class ClientPatcher { const lastCharFirstByte = result[lastCharPos]; if (lastCharFirstByte === oldLastCharByte) { - newUtf16NoLast.copy(result, pos); - - result[lastCharPos] = newLastCharByte; - - if (lastCharPos + 1 < result.length) { - const secondByte = result[lastCharPos + 1]; - if (secondByte === 0x00) { - console.log(` Patched UTF-16LE occurrence at offset 0x${pos.toString(16)}`); - } else { - console.log(` Patched length-prefixed occurrence at offset 0x${pos.toString(16)} (metadata: 0x${secondByte.toString(16)})`); - } + // Zero-fill first if new is shorter + if (newUtf16NoLast.length < oldUtf16NoLast.length) { + result.fill(0x00, pos, pos + oldUtf16NoLast.length); } + + newUtf16NoLast.copy(result, pos); + result[lastCharPos] = newLastCharByte; count++; } } @@ -238,7 +230,6 @@ class ClientPatcher { /** * Apply all domain patches using length-prefixed format - * This is the main patching method for variable-length domains */ applyDomainPatches(data, domain, protocol = 'https://') { let result = Buffer.from(data); @@ -251,28 +242,26 @@ class ClientPatcher { const oldSentry = 'https://ca900df42fcf57d4dd8401a86ddd7da2@sentry.hytale.com/2'; const newSentry = `${protocol}t@${domain}/2`; - console.log(` Patching sentry: ${oldSentry.slice(0, 30)}... -> ${newSentry}`); const sentryResult = this.replaceBytes( - result, - this.stringToLengthPrefixed(oldSentry), - this.stringToLengthPrefixed(newSentry) + result, + this.stringToLengthPrefixed(oldSentry), + this.stringToLengthPrefixed(newSentry) ); result = sentryResult.buffer; if (sentryResult.count > 0) { - console.log(` Replaced ${sentryResult.count} sentry occurrence(s)`); + console.log(` Patched ${sentryResult.count} sentry URL(s)`); totalCount += sentryResult.count; } // 2. Patch main domain (hytale.com -> mainDomain) - console.log(` Patching domain: ${ORIGINAL_DOMAIN} -> ${strategy.mainDomain}`); const domainResult = this.replaceBytes( - result, - this.stringToLengthPrefixed(ORIGINAL_DOMAIN), - this.stringToLengthPrefixed(strategy.mainDomain) + result, + this.stringToLengthPrefixed(ORIGINAL_DOMAIN), + this.stringToLengthPrefixed(strategy.mainDomain) ); result = domainResult.buffer; if (domainResult.count > 0) { - console.log(` Replaced ${domainResult.count} domain occurrence(s)`); + console.log(` Patched ${domainResult.count} domain occurrence(s)`); totalCount += domainResult.count; } @@ -281,15 +270,14 @@ class ClientPatcher { const newSubdomainPrefix = protocol + strategy.subdomainPrefix; for (const sub of subdomains) { - console.log(` Patching subdomain: ${sub} -> ${newSubdomainPrefix}`); const subResult = this.replaceBytes( - result, - this.stringToLengthPrefixed(sub), - this.stringToLengthPrefixed(newSubdomainPrefix) + result, + this.stringToLengthPrefixed(sub), + this.stringToLengthPrefixed(newSubdomainPrefix) ); result = subResult.buffer; if (subResult.count > 0) { - console.log(` Replaced ${subResult.count} occurrence(s)`); + console.log(` Patched ${subResult.count} ${sub} occurrence(s)`); totalCount += subResult.count; } } @@ -297,50 +285,13 @@ class ClientPatcher { return { buffer: result, count: totalCount }; } - /** - * Patch Discord invite URLs from .gg/hytale to .gg/MHkEjepMQ7 - */ - patchDiscordUrl(data) { - let count = 0; - const result = Buffer.from(data); - - const oldUrl = '.gg/hytale'; - const newUrl = '.gg/MHkEjepMQ7'; - - // Try length-prefixed format first - const lpResult = this.replaceBytes( - result, - this.stringToLengthPrefixed(oldUrl), - this.stringToLengthPrefixed(newUrl) - ); - - if (lpResult.count > 0) { - return { buffer: lpResult.buffer, count: lpResult.count }; - } - - // Fallback to UTF-16LE - const oldUtf16 = this.stringToUtf16LE(oldUrl); - const newUtf16 = this.stringToUtf16LE(newUrl); - - const positions = this.findAllOccurrences(result, oldUtf16); - - for (const pos of positions) { - newUtf16.copy(result, pos); - count++; - } - - return { buffer: result, count }; - } - /** * Check if the client binary has already been patched - * Also verifies the binary actually contains the patched domain */ isPatchedAlready(clientPath) { const newDomain = this.getNewDomain(); const patchFlagFile = clientPath + this.patchedFlag; - // First check flag file if (fs.existsSync(patchFlagFile)) { try { const flagData = JSON.parse(fs.readFileSync(patchFlagFile, 'utf8')); @@ -358,7 +309,7 @@ class ClientPatcher { } } } catch (e) { - // Flag file corrupt or unreadable + // Flag file corrupt } } return false; @@ -378,8 +329,7 @@ class ClientPatcher { patchMode: strategy.mode, mainDomain: strategy.mainDomain, subdomainPrefix: strategy.subdomainPrefix, - patcherVersion: '2.0.0', - verified: 'binary_contents' + patcherVersion: '2.1.0' }; fs.writeFileSync(patchFlagFile, JSON.stringify(flagData, null, 2)); } @@ -395,12 +345,10 @@ class ClientPatcher { return backupPath; } - // Check if current file differs from backup (might have been updated) const currentSize = fs.statSync(clientPath).size; const backupSize = fs.statSync(backupPath).size; if (currentSize !== backupSize) { - // File was updated, create timestamped backup of old backup const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); const oldBackupPath = `${clientPath}.original.${timestamp}`; console.log(` File updated, archiving old backup to ${path.basename(oldBackupPath)}`); @@ -433,22 +381,15 @@ class ClientPatcher { /** * Patch the client binary to use the custom domain - * @param {string} clientPath - Path to the HytaleClient binary - * @param {function} progressCallback - Optional callback for progress updates - * @returns {object} Result object with success status and details */ async patchClient(clientPath, progressCallback) { const newDomain = this.getNewDomain(); const strategy = this.getDomainStrategy(newDomain); - console.log('=== Client Patcher v2.0 ==='); + console.log('=== Client Patcher v2.1 ==='); console.log(`Target: ${clientPath}`); console.log(`Domain: ${newDomain} (${newDomain.length} chars)`); console.log(`Mode: ${strategy.mode}`); - if (strategy.mode === 'split') { - console.log(` Subdomain prefix: ${strategy.subdomainPrefix}`); - console.log(` Main domain: ${strategy.mainDomain}`); - } if (!fs.existsSync(clientPath)) { const error = `Client binary not found: ${clientPath}`; @@ -458,41 +399,29 @@ class ClientPatcher { if (this.isPatchedAlready(clientPath)) { console.log(`Client already patched for ${newDomain}, skipping`); - if (progressCallback) { - progressCallback('Client already patched', 100); - } + if (progressCallback) progressCallback('Client already patched', 100); return { success: true, alreadyPatched: true, patchCount: 0 }; } - if (progressCallback) { - progressCallback('Preparing to patch client...', 10); - } + if (progressCallback) progressCallback('Preparing to patch client...', 10); console.log('Creating backup...'); this.backupClient(clientPath); - if (progressCallback) { - progressCallback('Reading client binary...', 20); - } + if (progressCallback) progressCallback('Reading client binary...', 20); console.log('Reading client binary...'); const data = fs.readFileSync(clientPath); console.log(`Binary size: ${(data.length / 1024 / 1024).toFixed(2)} MB`); - if (progressCallback) { - progressCallback('Patching domain references...', 50); - } + if (progressCallback) progressCallback('Patching domain references...', 50); - console.log('Applying domain patches (length-prefixed format)...'); + console.log('Applying domain patches...'); const { buffer: patchedData, count } = this.applyDomainPatches(data, newDomain); - console.log('Patching Discord URLs...'); - const { buffer: finalData, count: discordCount } = this.patchDiscordUrl(patchedData); - - if (count === 0 && discordCount === 0) { + if (count === 0) { + // Try legacy UTF-16LE format console.log('No occurrences found - trying legacy UTF-16LE format...'); - - // Fallback to legacy patching for older binary formats const legacyResult = this.findAndReplaceDomainSmart(data, ORIGINAL_DOMAIN, strategy.mainDomain); if (legacyResult.count > 0) { console.log(`Found ${legacyResult.count} occurrences with legacy format`); @@ -501,40 +430,31 @@ class ClientPatcher { return { success: true, patchCount: legacyResult.count, format: 'legacy' }; } - console.log('No occurrences found - binary may already be modified or has different format'); + console.log('No occurrences found - binary may already be modified'); return { success: true, patchCount: 0, warning: 'No occurrences found' }; } - if (progressCallback) { - progressCallback('Writing patched binary...', 80); - } + if (progressCallback) progressCallback('Writing patched binary...', 80); console.log('Writing patched binary...'); - fs.writeFileSync(clientPath, finalData); - + fs.writeFileSync(clientPath, patchedData); this.markAsPatched(clientPath); - if (progressCallback) { - progressCallback('Patching complete', 100); - } + if (progressCallback) progressCallback('Patching complete', 100); - console.log(`Successfully patched ${count} domain occurrences and ${discordCount} Discord URLs`); + console.log(`Successfully patched ${count} occurrences`); console.log('=== Patching Complete ==='); - return { success: true, patchCount: count + discordCount }; + return { success: true, patchCount: count }; } /** * Patch the server JAR by downloading pre-patched version - * @param {string} serverPath - Path to the HytaleServer.jar - * @param {function} progressCallback - Optional callback for progress updates - * @param {string} javaPath - Path to Java executable (unused, kept for compatibility) - * @returns {object} Result object with success status and details */ async patchServer(serverPath, progressCallback, javaPath = null) { const newDomain = this.getNewDomain(); - console.log('=== Server Patcher TEMP SYSTEM NEED TO BE FIXED ==='); + console.log('=== Server Patcher ==='); console.log(`Target: ${serverPath}`); console.log(`Domain: ${newDomain}`); @@ -544,7 +464,6 @@ class ClientPatcher { return { success: false, error }; } - // Check if already patched const patchFlagFile = serverPath + '.dualauth_patched'; if (fs.existsSync(patchFlagFile)) { try { @@ -555,16 +474,14 @@ class ClientPatcher { return { success: true, alreadyPatched: true }; } } catch (e) { - // Flag file corrupt, re-patch + // Re-patch } } - // Create backup if (progressCallback) progressCallback('Creating backup...', 10); console.log('Creating backup...'); this.backupClient(serverPath); - // Download pre-patched JAR if (progressCallback) progressCallback('Downloading patched server JAR...', 30); console.log('Downloading pre-patched HytaleServer.jar'); @@ -573,55 +490,37 @@ class ClientPatcher { const url = 'https://pub-027b315ece074e2e891002ca38384792.r2.dev/HytaleServer.jar'; await new Promise((resolve, reject) => { - https.get(url, (response) => { + const handleResponse = (response) => { if (response.statusCode === 302 || response.statusCode === 301) { - // Follow redirect - https.get(response.headers.location, (redirectResponse) => { - if (redirectResponse.statusCode !== 200) { - reject(new Error(`Failed to download: HTTP ${redirectResponse.statusCode}`)); - return; - } - - const file = fs.createWriteStream(serverPath); - const totalSize = parseInt(redirectResponse.headers['content-length'], 10); - let downloaded = 0; - - redirectResponse.on('data', (chunk) => { - downloaded += chunk.length; - if (progressCallback && totalSize) { - const percent = 30 + Math.floor((downloaded / totalSize) * 60); - progressCallback(`Downloading... ${(downloaded / 1024 / 1024).toFixed(2)} MB`, percent); - } - }); - - redirectResponse.pipe(file); - file.on('finish', () => { - file.close(); - resolve(); - }); - }).on('error', reject); - } else if (response.statusCode === 200) { - const file = fs.createWriteStream(serverPath); - const totalSize = parseInt(response.headers['content-length'], 10); - let downloaded = 0; - - response.on('data', (chunk) => { - downloaded += chunk.length; - if (progressCallback && totalSize) { - const percent = 30 + Math.floor((downloaded / totalSize) * 60); - progressCallback(`Downloading... ${(downloaded / 1024 / 1024).toFixed(2)} MB`, percent); - } - }); - - response.pipe(file); - file.on('finish', () => { - file.close(); - resolve(); - }); - } else { - reject(new Error(`Failed to download: HTTP ${response.statusCode}`)); + https.get(response.headers.location, handleResponse).on('error', reject); + return; } - }).on('error', (err) => { + + if (response.statusCode !== 200) { + reject(new Error(`HTTP ${response.statusCode}`)); + return; + } + + const file = fs.createWriteStream(serverPath); + const totalSize = parseInt(response.headers['content-length'], 10); + let downloaded = 0; + + response.on('data', (chunk) => { + downloaded += chunk.length; + if (progressCallback && totalSize) { + const percent = 30 + Math.floor((downloaded / totalSize) * 60); + progressCallback(`Downloading... ${(downloaded / 1024 / 1024).toFixed(2)} MB`, percent); + } + }); + + response.pipe(file); + file.on('finish', () => { + file.close(); + resolve(); + }); + }; + + https.get(url, handleResponse).on('error', (err) => { fs.unlink(serverPath, () => {}); reject(err); }); @@ -629,12 +528,11 @@ class ClientPatcher { console.log(' Download successful'); - // Mark as patched fs.writeFileSync(patchFlagFile, JSON.stringify({ domain: newDomain, patchedAt: new Date().toISOString(), patcher: 'PrePatchedDownload', - source: 'https://pub-027b315ece074e2e891002ca38384792.r2.dev/HytaleServer.jar' + source: url })); if (progressCallback) progressCallback('Server patching complete', 100); @@ -643,8 +541,7 @@ class ClientPatcher { } catch (downloadError) { console.error(`Failed to download patched JAR: ${downloadError.message}`); - - // Restore backup on failure + const backupPath = serverPath + '.original'; if (fs.existsSync(backupPath)) { fs.copyFileSync(backupPath, serverPath); @@ -656,288 +553,38 @@ class ClientPatcher { } /** - * Find Java executable - uses bundled JRE first (same as game uses) - * Falls back to system Java if bundled not available + * Find Java executable */ findJava() { - // 1. Try bundled JRE first (comes with the game) try { const bundled = getBundledJavaPath(JRE_DIR); if (bundled && fs.existsSync(bundled)) { - console.log(`Using bundled Java: ${bundled}`); return bundled; } - } catch (e) { - // Bundled not available - } + } catch (e) {} - // 2. Try javaManager's getJavaExec (handles all fallbacks) try { const javaExec = getJavaExec(JRE_DIR); if (javaExec && fs.existsSync(javaExec)) { - console.log(`Using Java from javaManager: ${javaExec}`); return javaExec; } - } catch (e) { - // Not available - } + } catch (e) {} - // 3. Check JAVA_HOME if (process.env.JAVA_HOME) { - const javaHome = process.env.JAVA_HOME; - const javaBin = path.join(javaHome, 'bin', process.platform === 'win32' ? 'java.exe' : 'java'); + const javaBin = path.join(process.env.JAVA_HOME, 'bin', process.platform === 'win32' ? 'java.exe' : 'java'); if (fs.existsSync(javaBin)) { - console.log(`Using Java from JAVA_HOME: ${javaBin}`); return javaBin; } } - // 4. Try 'java' from PATH try { execSync('java -version 2>&1', { encoding: 'utf8' }); - console.log('Using Java from PATH'); return 'java'; - } catch (e) { - // Not in PATH - } + } catch (e) {} return null; } - /** - * Download DualAuthPatcher from hytale-auth-server if not present - */ - async ensurePatcherDownloaded(patcherDir) { - const patcherJava = path.join(patcherDir, 'DualAuthPatcher.java'); - const patcherUrl = 'https://raw.githubusercontent.com/sanasol/hytale-auth-server/master/patcher/DualAuthPatcher.java'; - - if (!fs.existsSync(patcherDir)) { - fs.mkdirSync(patcherDir, { recursive: true }); - } - - if (!fs.existsSync(patcherJava)) { - console.log('Downloading DualAuthPatcher from hytale-auth-server...'); - try { - const https = require('https'); - await new Promise((resolve, reject) => { - const file = fs.createWriteStream(patcherJava); - https.get(patcherUrl, (response) => { - if (response.statusCode === 302 || response.statusCode === 301) { - // Follow redirect - https.get(response.headers.location, (redirectResponse) => { - redirectResponse.pipe(file); - file.on('finish', () => { - file.close(); - resolve(); - }); - }).on('error', reject); - } else { - response.pipe(file); - file.on('finish', () => { - file.close(); - resolve(); - }); - } - }).on('error', (err) => { - fs.unlink(patcherJava, () => {}); - reject(err); - }); - }); - console.log(' Downloaded DualAuthPatcher.java'); - } catch (e) { - console.error(` Failed to download DualAuthPatcher: ${e.message}`); - throw e; - } - } - } - - /** - * Download ASM libraries if not present - */ - async ensureAsmLibraries(libDir) { - if (!fs.existsSync(libDir)) { - fs.mkdirSync(libDir, { recursive: true }); - } - - const libs = [ - { name: 'asm-9.6.jar', url: 'https://repo1.maven.org/maven2/org/ow2/asm/asm/9.6/asm-9.6.jar' }, - { name: 'asm-tree-9.6.jar', url: 'https://repo1.maven.org/maven2/org/ow2/asm/asm-tree/9.6/asm-tree-9.6.jar' }, - { name: 'asm-util-9.6.jar', url: 'https://repo1.maven.org/maven2/org/ow2/asm/asm-util/9.6/asm-util-9.6.jar' } - ]; - - for (const lib of libs) { - const libPath = path.join(libDir, lib.name); - if (!fs.existsSync(libPath)) { - console.log(`Downloading ${lib.name}...`); - try { - const https = require('https'); - await new Promise((resolve, reject) => { - const file = fs.createWriteStream(libPath); - https.get(lib.url, (response) => { - response.pipe(file); - file.on('finish', () => { - file.close(); - resolve(); - }); - }).on('error', (err) => { - fs.unlink(libPath, () => {}); - reject(err); - }); - }); - console.log(` Downloaded ${lib.name}`); - } catch (e) { - console.error(` Failed to download ${lib.name}: ${e.message}`); - throw e; - } - } - } - } - - /** - * Compile DualAuthPatcher if needed - */ - async compileDualAuthPatcher(java, patcherDir, libDir) { - const patcherClass = path.join(patcherDir, 'DualAuthPatcher.class'); - const patcherJava = path.join(patcherDir, 'DualAuthPatcher.java'); - - // Check if already compiled and up to date - if (fs.existsSync(patcherClass)) { - const classTime = fs.statSync(patcherClass).mtime; - const javaTime = fs.statSync(patcherJava).mtime; - if (classTime > javaTime) { - console.log('DualAuthPatcher already compiled'); - return { success: true }; - } - } - - console.log('Compiling DualAuthPatcher...'); - - const javac = java.replace(/java(\.exe)?$/, 'javac$1'); - const classpath = [ - path.join(libDir, 'asm-9.6.jar'), - path.join(libDir, 'asm-tree-9.6.jar'), - path.join(libDir, 'asm-util-9.6.jar') - ].join(process.platform === 'win32' ? ';' : ':'); - - try { - // Fix PATH for packaged Electron apps on Windows - const execOptions = { - stdio: 'pipe', - cwd: patcherDir, - env: { ...process.env } - }; - - // Add system32 to PATH for Windows to find cmd.exe - if (process.platform === 'win32') { - const systemRoot = process.env.SystemRoot || 'C:\\WINDOWS'; - const systemPath = `${systemRoot}\\system32;${systemRoot};${systemRoot}\\System32\\Wbem`; - execOptions.env.PATH = execOptions.env.PATH - ? `${systemPath};${execOptions.env.PATH}` - : systemPath; - execOptions.shell = true; - } - - execSync(`"${javac}" -cp "${classpath}" -d "${patcherDir}" "${patcherJava}"`, execOptions); - console.log(' Compilation successful'); - return { success: true }; - } catch (e) { - const error = `Failed to compile DualAuthPatcher: ${e.message}`; - console.error(error); - if (e.stderr) console.error(e.stderr.toString()); - return { success: false, error }; - } - } - - /** - * Run DualAuthPatcher on the server JAR - */ - async runDualAuthPatcher(java, classpath, serverPath, domain) { - return new Promise((resolve) => { - const args = ['-cp', classpath, 'DualAuthPatcher', serverPath]; - const env = { ...process.env, HYTALE_AUTH_DOMAIN: domain }; - - console.log(`Running: java ${args.join(' ')}`); - console.log(` HYTALE_AUTH_DOMAIN=${domain}`); - - const proc = spawn(java, args, { env, stdio: ['pipe', 'pipe', 'pipe'] }); - - let stdout = ''; - let stderr = ''; - - proc.stdout.on('data', (data) => { - const str = data.toString(); - stdout += str; - console.log(str.trim()); - }); - - proc.stderr.on('data', (data) => { - const str = data.toString(); - stderr += str; - console.error(str.trim()); - }); - - proc.on('close', (code) => { - if (code === 0) { - resolve({ success: true, stdout }); - } else { - resolve({ success: false, error: `Patcher exited with code ${code}: ${stderr}` }); - } - }); - - proc.on('error', (err) => { - resolve({ success: false, error: `Failed to run patcher: ${err.message}` }); - }); - }); - } - - /** - * Legacy server patcher (simple domain replacement, no dual auth) - * Use patchServer() for full dual auth support - */ - async patchServerLegacy(serverPath, progressCallback) { - const newDomain = this.getNewDomain(); - const strategy = this.getDomainStrategy(newDomain); - - console.log('=== Legacy Server Patcher ==='); - console.log(`Target: ${serverPath}`); - console.log(`Domain: ${newDomain} (${newDomain.length} chars)`); - - if (!fs.existsSync(serverPath)) { - return { success: false, error: `Server JAR not found: ${serverPath}` }; - } - - if (progressCallback) progressCallback('Patching server...', 20); - - console.log('Opening server JAR...'); - const zip = new AdmZip(serverPath); - const entries = zip.getEntries(); - - let totalCount = 0; - const oldUtf8 = this.stringToUtf8(ORIGINAL_DOMAIN); - - for (const entry of entries) { - const name = entry.entryName; - if (name.endsWith('.class') || name.endsWith('.properties') || - name.endsWith('.json') || name.endsWith('.xml') || name.endsWith('.yml')) { - const data = entry.getData(); - if (data.includes(oldUtf8)) { - const { buffer: patchedData, count } = this.findAndReplaceDomainUtf8(data, ORIGINAL_DOMAIN, strategy.mainDomain); - if (count > 0) { - zip.updateFile(entry.entryName, patchedData); - totalCount += count; - } - } - } - } - - if (totalCount > 0) { - zip.writeZip(serverPath); - } - - if (progressCallback) progressCallback('Complete', 100); - return { success: true, patchCount: totalCount }; - } - /** * Find the client binary path based on platform */ @@ -961,7 +608,6 @@ class ClientPatcher { return null; } - findServerPath(gameDir) { const candidates = [ path.join(gameDir, 'Server', 'HytaleServer.jar'), @@ -978,9 +624,6 @@ class ClientPatcher { /** * Ensure both client and server are patched before launching - * @param {string} gameDir - Path to the game directory - * @param {function} progressCallback - Optional callback for progress updates - * @param {string} javaPath - Optional path to Java executable for server patching */ async ensureClientPatched(gameDir, progressCallback, javaPath = null) { const results = { @@ -991,13 +634,9 @@ class ClientPatcher { const clientPath = this.findClientPath(gameDir); if (clientPath) { - if (progressCallback) { - progressCallback('Patching client binary...', 10); - } + if (progressCallback) progressCallback('Patching client binary...', 10); results.client = await this.patchClient(clientPath, (msg, pct) => { - if (progressCallback) { - progressCallback(`Client: ${msg}`, pct ? pct / 2 : null); - } + if (progressCallback) progressCallback(`Client: ${msg}`, pct ? pct / 2 : null); }); } else { console.warn('Could not find HytaleClient binary'); @@ -1006,13 +645,9 @@ class ClientPatcher { const serverPath = this.findServerPath(gameDir); if (serverPath) { - if (progressCallback) { - progressCallback('Patching server JAR...', 50); - } + if (progressCallback) progressCallback('Patching server JAR...', 50); results.server = await this.patchServer(serverPath, (msg, pct) => { - if (progressCallback) { - progressCallback(`Server: ${msg}`, pct ? 50 + pct / 2 : null); - } + if (progressCallback) progressCallback(`Server: ${msg}`, pct ? 50 + pct / 2 : null); }, javaPath); } else { console.warn('Could not find HytaleServer.jar'); @@ -1023,9 +658,7 @@ class ClientPatcher { results.alreadyPatched = (results.client && results.client.alreadyPatched) && (results.server && results.server.alreadyPatched); results.patchCount = (results.client ? results.client.patchCount || 0 : 0) + (results.server ? results.server.patchCount || 0 : 0); - if (progressCallback) { - progressCallback('Patching complete', 100); - } + if (progressCallback) progressCallback('Patching complete', 100); return results; } diff --git a/docs/STEAMDECK_CRASH_INVESTIGATION.md b/docs/STEAMDECK_CRASH_INVESTIGATION.md new file mode 100644 index 0000000..87e1d50 --- /dev/null +++ b/docs/STEAMDECK_CRASH_INVESTIGATION.md @@ -0,0 +1,201 @@ +# Steam Deck / Ubuntu LTS Crash Investigation + +## Problem Summary + +The Hytale F2P launcher's client patcher causes crashes on Steam Deck and Ubuntu LTS with the error: +``` +free(): invalid pointer +``` + +The crash occurs after successful authentication, specifically right after "Finished handling RequiredAssets". + +**Affected Systems:** +- Steam Deck (glibc 2.41) +- Ubuntu LTS + +**Working Systems:** +- macOS +- Windows +- Arch Linux + +**Critical Finding:** The UNPATCHED original binary works fine on Steam Deck. The crash is caused by our patching. + +--- + +## String Occurrences Found + +### UTF-16LE Format (3 occurrences) +Found by `HYTALE_PATCH_MODE=utf16le`: + +| Index | Offset | Before Context | After Context | Likely URL | +|-------|--------|----------------|---------------|------------| +| 0 | 0x1bc5ad7 | `try.` | `/2i3...` | `sentry.hytale.com/2...` | +| 1 | 0x1bc5b3f | `s://` | `/hel` | `https://hytale.com/help...` | +| 2 | 0x1bc5bc9 | `ore.` | `/?up` | `store.hytale.com/?up...` | + +### Length-Prefixed Format (1 occurrence) +Found by default `length-prefixed` mode: + +| Offset | Before | After | Notes | +|--------|--------|-------|-------| +| 0x1bc5d63 | `5933b8` | `89338807` | **Surrounded by what looks like x86 code!** | + +--- + +## Critical Finding: Binary Diff Analysis + +When patching with length-prefixed mode (single occurrence): + +``` +< 01bc5d60: 5933 b80a 0000 0068 0079 0074 0061 006c Y3.....h.y.t.a.l +< 01bc5d70: 0065 002e 0063 006f 006d 8933 8807 0000 .e...c.o.m.3.... +--- +> 01bc5d60: 5933 b80a 0000 0073 0061 006e 0061 0073 Y3.....s.a.n.a.s +> 01bc5d70: 006f 006c 002e 0077 0073 8933 8807 0000 .o.l...w.s.3.... +``` + +**Structure at 0x1bc5d60:** +``` +5933 b8 | 0a000000 | 68007900740061006c0065002e0063006f006d | 8933 8807 0000 +???????? | len=10 | h.y.t.a.l.e...c.o.m | mov [rbx],esi? +``` + +- `5933 b8` before the string - could be code or metadata +- `0a 00 00 00` - .NET length prefix (10 characters) +- String content in UTF-16LE +- `89 33` after - this is `mov [rbx], esi` in x86-64! + +**The string appears to be embedded near executable code, not in a clean data section.** + +--- + +## Test Results Summary + +| Test | Occurrences Patched | Auth Works | Crashes | +|------|---------------------|------------|---------| +| Length-prefixed (default) | 1 at 0x1bc5d63 | YES | YES | +| UTF-16LE mode | 3 at 0x1bc5ad7, 0x1bc5b3f, 0x1bc5bc9 | YES | YES | +| Skip all UTF-16LE | 0 (but legacy fallback patched 4!) | YES | YES | +| Original unpatched | 0 | NO (wrong issuer) | NO | + +**Key Insight:** Even patching just ONE string (the length-prefixed one) causes the crash, yet authentication succeeds before the crash. + +--- + +## GDB Stack Trace + +``` +#0 0x00007ffff7d3f5a4 in ?? () from /usr/lib/libc.so.6 +#1 raise () from /usr/lib/libc.so.6 +#2 abort () from /usr/lib/libc.so.6 +#3-#4 ?? () from /usr/lib/libc.so.6 +#5 free () from /usr/lib/libc.so.6 +#6 ?? () from libzstd.so <-- CRASH POINT +#7-#24 HytaleClient code (asset decompression) +``` + +Crash occurs in `libzstd.so` during `free()` after "Finished handling RequiredAssets". + +--- + +## Hypotheses + +### 1. .NET String Interning +.NET AOT may have precomputed hashes or metadata for interned strings. Modifying the string content breaks the hash, causing memory corruption when the runtime tries to use it. + +### 2. Code/Data Boundary Issue +The string at 0x1bc5d63 appears to be embedded near x86 code (`89 33` = `mov [rbx], esi`). Modifying it might corrupt instruction decoding or memory layout calculations. + +### 3. Checksums/Integrity +The binary may have checksums for certain data sections that we're invalidating. + +### 4. Memory Alignment +glibc 2.41's stricter heap validation may catch alignment issues that older versions ignore. + +--- + +## Debug Environment Variables + +| Variable | Description | Example | +|----------|-------------|---------| +| `HYTALE_AUTH_DOMAIN` | Target domain | `sanasol.ws` | +| `HYTALE_PATCH_MODE` | `utf16le` or `length-prefixed` | `utf16le` | +| `HYTALE_SKIP_SENTRY_PATCH` | Skip sentry URL patch | `1` | +| `HYTALE_SKIP_SUBDOMAIN_PATCH` | Skip subdomain patches | `1` | +| `HYTALE_PATCH_LIMIT` | Max patches to apply | `1` | +| `HYTALE_PATCH_SKIP` | Comma-separated indices to skip | `0,2` | +| `HYTALE_NO_LEGACY_FALLBACK` | Disable legacy fallback | `1` | +| `HYTALE_NOOP_TEST` | Read/write without patching | `1` | + +--- + +## Files & Offsets Reference + +**Binary:** `HytaleClient` (ELF 64-bit, ~39.9 MB) + +| Offset | Format | Content | +|--------|--------|---------| +| 0x1bc5ad7 | UTF-16LE | `sentry.hytale.com/...` | +| 0x1bc5b3f | UTF-16LE | `https://hytale.com/help...` | +| 0x1bc5bc9 | UTF-16LE | `store.hytale.com/?...` | +| 0x1bc5d63 | Length-prefixed | Main session URL (surrounded by code?) | + +--- + +## SOLUTION FOUND ✓ + +### Root Cause +The crash is caused by **glibc 2.41's stricter heap validation** catching a pre-existing race condition in the .NET AOT runtime or asset decompression code. Our binary patching triggers this timing-dependent bug, but the patching itself is correct. + +### Evidence +- Valgrind showed NO memory corruption errors +- Game ran successfully under Valgrind (slower execution avoids the race) +- Game was manually killed (SIGINT), not crashed +- 1.4M allocations with no "Invalid free" detected + +### Fix: Use jemalloc allocator +jemalloc handles the race condition gracefully. The launcher now auto-detects and uses jemalloc on Linux. + +**Install jemalloc:** +```bash +# Steam Deck / Arch Linux +sudo pacman -S jemalloc + +# Ubuntu / Debian +sudo apt install libjemalloc2 + +# Fedora / RHEL +sudo dnf install jemalloc +``` + +The launcher automatically uses jemalloc if found at: +- `/usr/lib/libjemalloc.so.2` (Arch, Steam Deck) +- `/usr/lib/x86_64-linux-gnu/libjemalloc.so.2` (Debian/Ubuntu) +- `/usr/lib64/libjemalloc.so.2` (Fedora/RHEL) + +**Manual workaround (if launcher doesn't detect):** +```bash +LD_PRELOAD=/usr/lib/libjemalloc.so.2 ./Client/HytaleClient ... +``` + +**Disable jemalloc (for testing):** +```bash +HYTALE_NO_JEMALLOC=1 npm start +``` + +--- + +## Previous Investigation (for reference) + +### Next Steps (COMPLETED) + +1. ~~Try runtime hooking instead of binary patching~~ - Not needed, jemalloc fixes the issue +2. ~~Investigate .NET AOT string metadata~~ - Not the root cause +3. ~~Test on different glibc versions~~ - Confirmed glibc 2.41 specific +4. ~~Examine libzstd interaction~~ - libzstd's free() was just where the corruption manifested + +--- + +## Branch + +`fix/patcher-memory-corruption-v2` diff --git a/docs/STEAMDECK_DEBUG_COMMANDS.md b/docs/STEAMDECK_DEBUG_COMMANDS.md new file mode 100644 index 0000000..6ac74d2 --- /dev/null +++ b/docs/STEAMDECK_DEBUG_COMMANDS.md @@ -0,0 +1,95 @@ +# Steam Deck / Linux Crash Fix + +## SOLUTION: Use jemalloc ✓ + +The crash is caused by glibc 2.41's stricter heap validation. Using jemalloc as the memory allocator fixes the issue. + +### Install jemalloc + +```bash +# Steam Deck / Arch Linux +sudo pacman -S jemalloc + +# Ubuntu / Debian +sudo apt install libjemalloc2 + +# Fedora / RHEL +sudo dnf install jemalloc +``` + +### Launcher Auto-Detection + +The launcher automatically uses jemalloc when installed. No manual configuration needed. + +To disable (for testing): +```bash +HYTALE_NO_JEMALLOC=1 npm start +``` + +### Manual Launch with jemalloc + +```bash +cd ~/.hytalef2p/release/package/game/latest + +LD_PRELOAD=/usr/lib/libjemalloc.so.2 ./Client/HytaleClient --app-dir /home/deck/.hytalef2p/release/package/game/latest --java-exec /home/deck/.hytalef2p/release/package/jre/latest/bin/java --auth-mode authenticated --uuid YOUR_UUID --name Player --identity-token YOUR_TOKEN --session-token YOUR_TOKEN --user-dir /home/deck/.hytalesaves +``` + +--- + +## Debug Commands (for troubleshooting) + +### Base Command +```bash +cd ~/.hytalef2p/release/package/game/latest +``` + +### GDB Stack Trace (for crash analysis) +```bash +gdb -ex "run --app-dir ..." ./Client/HytaleClient + +# After crash: +bt +bt full +info registers +quit +``` + +### Test glibc tunables (alternative fixes that didn't work reliably) + +**Disable tcache:** +```bash +GLIBC_TUNABLES=glibc.malloc.tcache_count=0 ./Client/HytaleClient ... +``` + +**Disable heap validation:** +```bash +MALLOC_CHECK_=0 ./Client/HytaleClient ... +``` + +### Binary Validation +```bash +file ~/.hytalef2p/release/package/game/latest/Client/HytaleClient +ldd ~/.hytalef2p/release/package/game/latest/Client/HytaleClient +``` + +### Hex Dump Commands +```bash +# Search for hytale.com UTF-16LE +xxd ~/.hytalef2p/release/package/game/latest/Client/HytaleClient.original | grep "6800 7900 7400 6100 6c00 6500 2e00 6300 6f00 6d00" +``` + +--- + +## Test Different Patch Modes + +```bash +# Restore original +cp ~/.hytalef2p/release/package/game/latest/Client/HytaleClient.original ~/.hytalef2p/release/package/game/latest/Client/HytaleClient +rm ~/.hytalef2p/release/package/game/latest/Client/HytaleClient.patched_custom + +# Test UTF-16LE mode +HYTALE_PATCH_MODE=utf16le HYTALE_AUTH_DOMAIN=sanasol.ws npm start + +# Test length-prefixed mode (default) +HYTALE_AUTH_DOMAIN=sanasol.ws npm start +```