mirror of
https://git.sanhost.net/sanasol/hytale-f2p.git
synced 2026-02-26 06:41:47 -03:00
Attempts to fix "free(): invalid pointer" crashes on Steam Deck and Ubuntu LTS. The same patched binary works on macOS, Windows, and Arch Linux, suggesting the issue is glibc's strict heap validation rather than actual memory corruption. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
442 lines
15 KiB
JavaScript
442 lines
15 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
const { exec } = require('child_process');
|
|
const { promisify } = require('util');
|
|
const { spawn } = require('child_process');
|
|
const { v4: uuidv4 } = require('uuid');
|
|
const { getResolvedAppDir, findClientPath } = require('../core/paths');
|
|
const { setupWaylandEnvironment, setupGpuEnvironment } = require('../utils/platformUtils');
|
|
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 { syncModsForCurrentProfile } = require('./modManager');
|
|
const { getUserDataPath } = require('../utils/userDataMigration');
|
|
|
|
// Client patcher for custom auth server (sanasol.ws)
|
|
let clientPatcher = null;
|
|
try {
|
|
clientPatcher = require('../utils/clientPatcher');
|
|
} catch (err) {
|
|
console.log('[Launcher] Client patcher not available:', err.message);
|
|
}
|
|
|
|
const execAsync = promisify(exec);
|
|
|
|
// Fetch tokens from the auth server (properly signed with server's Ed25519 key)
|
|
async function fetchAuthTokens(uuid, name) {
|
|
const authServerUrl = getAuthServerUrl();
|
|
try {
|
|
console.log(`Fetching auth tokens from ${authServerUrl}/game-session/child`);
|
|
|
|
const response = await fetch(`${authServerUrl}/game-session/child`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
uuid: uuid,
|
|
name: name,
|
|
scopes: ['hytale:server', 'hytale:client']
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Auth server returned ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
console.log('Auth tokens received from server');
|
|
|
|
return {
|
|
identityToken: data.IdentityToken || data.identityToken,
|
|
sessionToken: data.SessionToken || data.sessionToken
|
|
};
|
|
} catch (error) {
|
|
console.error('Failed to fetch auth tokens:', error.message);
|
|
// Fallback to local generation if server unavailable
|
|
return generateLocalTokens(uuid, name);
|
|
}
|
|
}
|
|
|
|
// Fallback: Generate tokens locally (won't pass signature validation but allows offline testing)
|
|
function generateLocalTokens(uuid, name) {
|
|
console.log('Using locally generated tokens (fallback mode)');
|
|
const authServerUrl = getAuthServerUrl();
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const exp = now + 36000;
|
|
|
|
const header = Buffer.from(JSON.stringify({
|
|
alg: 'EdDSA',
|
|
kid: '2025-10-01',
|
|
typ: 'JWT'
|
|
})).toString('base64url');
|
|
|
|
const identityPayload = Buffer.from(JSON.stringify({
|
|
sub: uuid,
|
|
name: name,
|
|
username: name,
|
|
entitlements: ['game.base'],
|
|
scope: 'hytale:server hytale:client',
|
|
iat: now,
|
|
exp: exp,
|
|
iss: authServerUrl,
|
|
jti: uuidv4()
|
|
})).toString('base64url');
|
|
|
|
const sessionPayload = Buffer.from(JSON.stringify({
|
|
sub: uuid,
|
|
scope: 'hytale:server',
|
|
iat: now,
|
|
exp: exp,
|
|
iss: authServerUrl,
|
|
jti: uuidv4()
|
|
})).toString('base64url');
|
|
|
|
const signature = crypto.randomBytes(64).toString('base64url');
|
|
|
|
return {
|
|
identityToken: `${header}.${identityPayload}.${signature}`,
|
|
sessionToken: `${header}.${sessionPayload}.${signature}`
|
|
};
|
|
}
|
|
|
|
async function launchGame(playerName = 'Player', progressCallback, javaPathOverride, installPathOverride, gpuPreference = 'auto', branchOverride = null) {
|
|
const branch = branchOverride || loadVersionBranch();
|
|
const customAppDir = getResolvedAppDir(installPathOverride);
|
|
const customGameDir = path.join(customAppDir, branch, 'package', 'game', 'latest');
|
|
const customJreDir = path.join(customAppDir, branch, 'package', 'jre', 'latest');
|
|
|
|
// NEW 2.1.2: Use centralized UserData location
|
|
const userDataDir = getUserDataPath();
|
|
|
|
const gameLatest = customGameDir;
|
|
let clientPath = findClientPath(gameLatest);
|
|
|
|
if (!clientPath) {
|
|
throw new Error('Game is not installed. Please install the game first.');
|
|
}
|
|
|
|
saveUsername(playerName);
|
|
if (installPathOverride) {
|
|
saveInstallPath(installPathOverride);
|
|
}
|
|
|
|
const configuredJava = (javaPathOverride !== undefined && javaPathOverride !== null
|
|
? javaPathOverride
|
|
: loadJavaPath() || '').trim();
|
|
let javaBin = null;
|
|
|
|
if (configuredJava) {
|
|
javaBin = await resolveJavaPath(configuredJava);
|
|
if (!javaBin) {
|
|
throw new Error(`Configured Java path not found: ${configuredJava}`);
|
|
}
|
|
} else {
|
|
javaBin = getJavaExec(customJreDir);
|
|
|
|
if (!getBundledJavaPath(customJreDir)) {
|
|
const fallback = await detectSystemJava();
|
|
if (fallback) {
|
|
javaBin = fallback;
|
|
} else {
|
|
throw new Error('Java runtime not found. Please install the game first or configure Java path.');
|
|
}
|
|
}
|
|
}
|
|
|
|
const uuid = getUuidForUser(playerName);
|
|
|
|
// Fetch tokens from auth server
|
|
if (progressCallback) {
|
|
progressCallback('Fetching authentication tokens...', null, null, null, null);
|
|
}
|
|
const { identityToken, sessionToken } = await fetchAuthTokens(uuid, playerName);
|
|
|
|
// Patch client and server binaries to use custom auth server (BEFORE signing on macOS)
|
|
// FORCE patch on every launch to ensure consistency
|
|
const authDomain = getAuthDomain();
|
|
if (clientPatcher) {
|
|
try {
|
|
if (progressCallback) {
|
|
progressCallback('Patching game for custom server...', null, null, null, null);
|
|
}
|
|
console.log(`Force patching game binaries for ${authDomain}...`);
|
|
|
|
const patchResult = await clientPatcher.ensureClientPatched(gameLatest, (msg, percent) => {
|
|
// console.log(`[Patcher] ${msg}`);
|
|
if (progressCallback && msg) {
|
|
progressCallback(msg, percent, null, null, null);
|
|
}
|
|
});
|
|
|
|
if (patchResult.success) {
|
|
console.log(`Game patched successfully (${patchResult.patchCount} total occurrences)`);
|
|
if (patchResult.client) {
|
|
console.log(` Client: ${patchResult.client.patchCount || 0} occurrences`);
|
|
}
|
|
if (patchResult.server) {
|
|
console.log(` Server: ${patchResult.server.patchCount || 0} occurrences`);
|
|
}
|
|
} else {
|
|
console.warn('Game patching failed:', patchResult.error);
|
|
}
|
|
} catch (patchError) {
|
|
console.warn('Game patching failed (game may not connect to custom server):', patchError.message);
|
|
}
|
|
}
|
|
|
|
// macOS: Sign binaries AFTER patching so the patched binaries have valid signatures
|
|
if (process.platform === 'darwin') {
|
|
try {
|
|
const appBundle = path.join(gameLatest, 'Client', 'Hytale.app');
|
|
const serverDir = path.join(gameLatest, 'Server');
|
|
|
|
const signPath = async (targetPath, deep = false) => {
|
|
await execAsync(`xattr -cr "${targetPath}"`).catch(() => { });
|
|
const deepFlag = deep ? '--deep ' : '';
|
|
await execAsync(`codesign --force ${deepFlag}--sign - "${targetPath}"`).catch(() => { });
|
|
};
|
|
|
|
if (fs.existsSync(appBundle)) {
|
|
await signPath(appBundle, true);
|
|
console.log('Signed macOS app bundle (after patching)');
|
|
} else {
|
|
await signPath(path.dirname(clientPath), true);
|
|
console.log('Signed macOS client binary (after patching)');
|
|
}
|
|
|
|
if (javaBin && fs.existsSync(javaBin)) {
|
|
let jreRoot = path.dirname(path.dirname(javaBin));
|
|
if (jreRoot.endsWith('Home')) {
|
|
jreRoot = path.dirname(path.dirname(jreRoot));
|
|
}
|
|
await signPath(jreRoot, true);
|
|
await signPath(javaBin, false);
|
|
console.log('Signed Java runtime');
|
|
}
|
|
|
|
if (fs.existsSync(serverDir)) {
|
|
await execAsync(`xattr -cr "${serverDir}"`).catch(() => { });
|
|
await execAsync(`find "${serverDir}" -type f -perm +111 -exec codesign --force --sign - {} \\;`).catch(() => { });
|
|
console.log('Signed server binaries (after patching)');
|
|
}
|
|
|
|
if (javaBin && fs.existsSync(javaBin)) {
|
|
const javaWrapperPath = path.join(path.dirname(javaBin), 'java-wrapper');
|
|
const wrapperScript = `#!/bin/bash
|
|
# Java wrapper for macOS - adds --disable-sentry to fix Sentry hang issue
|
|
REAL_JAVA="${javaBin}"
|
|
ARGS=("$@")
|
|
for i in "\${!ARGS[@]}"; do
|
|
if [[ "\${ARGS[$i]}" == *"HytaleServer.jar"* ]]; then
|
|
ARGS=("\${ARGS[@]:0:$((i+1))}" "--disable-sentry" "\${ARGS[@]:$((i+1))}")
|
|
break
|
|
fi
|
|
done
|
|
exec "$REAL_JAVA" "\${ARGS[@]}"
|
|
`;
|
|
fs.writeFileSync(javaWrapperPath, wrapperScript, { mode: 0o755 });
|
|
await signPath(javaWrapperPath, false);
|
|
console.log('Created java wrapper with --disable-sentry fix');
|
|
javaBin = javaWrapperPath;
|
|
}
|
|
} catch (signError) {
|
|
console.log('Notice: macOS signing step failed:', signError.message);
|
|
console.log('The game may still launch if Gatekeeper allows it');
|
|
}
|
|
}
|
|
|
|
const args = [
|
|
'--app-dir', gameLatest,
|
|
'--java-exec', javaBin,
|
|
'--auth-mode', 'authenticated',
|
|
'--uuid', uuid,
|
|
'--name', playerName,
|
|
'--identity-token', identityToken,
|
|
'--session-token', sessionToken,
|
|
'--user-dir', userDataDir
|
|
];
|
|
|
|
if (progressCallback) {
|
|
progressCallback('Starting game...', null, null, null, null);
|
|
}
|
|
|
|
// Ensure mods are synced for the active profile before launching
|
|
try {
|
|
console.log('Syncing mods for active profile before launch...');
|
|
if (progressCallback) progressCallback('Syncing mods...', null, null, null, null);
|
|
await syncModsForCurrentProfile();
|
|
} catch (syncError) {
|
|
console.error('Failed to sync mods before launch:', syncError);
|
|
// Continue anyway? Or fail?
|
|
// Warn user but continue might be safer to avoid blocking play if sync is just glitchy
|
|
}
|
|
|
|
console.log('Starting game...');
|
|
console.log(`Command: "${clientPath}" ${args.join(' ')}`);
|
|
|
|
const env = { ...process.env };
|
|
|
|
const waylandEnv = setupWaylandEnvironment();
|
|
Object.assign(env, waylandEnv);
|
|
|
|
const gpuEnv = setupGpuEnvironment(gpuPreference);
|
|
Object.assign(env, gpuEnv);
|
|
|
|
// Disable glibc heap validation on Linux to prevent false positive
|
|
// "free(): invalid pointer" crashes on Steam Deck and Ubuntu LTS
|
|
// This doesn't hide real bugs - the same binary works on other systems
|
|
if (process.platform === 'linux') {
|
|
env.MALLOC_CHECK_ = '0';
|
|
console.log('Linux detected: Setting MALLOC_CHECK_=0 to disable glibc heap validation');
|
|
}
|
|
|
|
try {
|
|
let spawnOptions = {
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
detached: true,
|
|
env: env
|
|
};
|
|
|
|
if (process.platform === 'win32') {
|
|
spawnOptions.shell = false;
|
|
spawnOptions.windowsHide = true;
|
|
}
|
|
|
|
const child = spawn(clientPath, args, spawnOptions);
|
|
|
|
console.log(`Game process started with PID: ${child.pid}`);
|
|
|
|
let hasExited = false;
|
|
let outputReceived = false;
|
|
|
|
child.stdout.on('data', (data) => {
|
|
outputReceived = true;
|
|
console.log(`Game output: ${data.toString().trim()}`);
|
|
});
|
|
|
|
child.stderr.on('data', (data) => {
|
|
outputReceived = true;
|
|
console.error(`Game error: ${data.toString().trim()}`);
|
|
});
|
|
|
|
child.on('error', (error) => {
|
|
hasExited = true;
|
|
console.error(`Failed to start game process: ${error.message}`);
|
|
if (progressCallback) {
|
|
progressCallback(`Failed to start game: ${error.message}`, -1, null, null, null);
|
|
}
|
|
});
|
|
|
|
child.on('exit', (code, signal) => {
|
|
hasExited = true;
|
|
if (code !== null) {
|
|
console.log(`Game process exited with code ${code}`);
|
|
if (code !== 0 && progressCallback) {
|
|
progressCallback(`Game exited with error code ${code}`, -1, null, null, null);
|
|
}
|
|
} else if (signal) {
|
|
console.log(`Game process terminated by signal ${signal}`);
|
|
}
|
|
});
|
|
|
|
// Monitor game process status in background
|
|
setTimeout(() => {
|
|
if (!hasExited) {
|
|
console.log('Game appears to be running successfully');
|
|
child.unref();
|
|
if (progressCallback) {
|
|
progressCallback('Game launched successfully', 100, null, null, null);
|
|
}
|
|
} else if (!outputReceived) {
|
|
console.warn('Game process exited immediately with no output - possible issue with game files or dependencies');
|
|
}
|
|
}, 3000);
|
|
|
|
// Return immediately, don't wait for setTimeout
|
|
return { success: true, installed: true, launched: true, pid: child.pid };
|
|
} catch (spawnError) {
|
|
console.error(`Error spawning game process: ${spawnError.message}`);
|
|
if (progressCallback) {
|
|
progressCallback(`Error launching game: ${spawnError.message}`, -1, null, null, null);
|
|
}
|
|
throw spawnError;
|
|
}
|
|
}
|
|
|
|
async function launchGameWithVersionCheck(playerName = 'Player', progressCallback, javaPathOverride, installPathOverride, gpuPreference = 'auto', branchOverride = null) {
|
|
try {
|
|
const branch = branchOverride || loadVersionBranch();
|
|
|
|
if (progressCallback) {
|
|
progressCallback('Checking for updates...', 0, null, null, null);
|
|
}
|
|
|
|
const installedVersion = loadVersionClient();
|
|
const latestVersion = await getLatestClientVersion(branch);
|
|
|
|
console.log(`Installed version: ${installedVersion}, Latest version: ${latestVersion} (branch: ${branch})`);
|
|
|
|
let needsUpdate = false;
|
|
if (!installedVersion || installedVersion !== latestVersion) {
|
|
needsUpdate = true;
|
|
console.log('Version mismatch or not installed, update required');
|
|
}
|
|
|
|
if (needsUpdate) {
|
|
if (progressCallback) {
|
|
progressCallback('Game update required, starting update process...', 10, null, null, null);
|
|
}
|
|
|
|
const customAppDir = getResolvedAppDir(installPathOverride);
|
|
const customGameDir = path.join(customAppDir, branch, 'package', 'game', 'latest');
|
|
const customToolsDir = path.join(customAppDir, 'butler');
|
|
const customCacheDir = path.join(customAppDir, 'cache');
|
|
|
|
try {
|
|
await updateGameFiles(latestVersion, progressCallback, customGameDir, customToolsDir, customCacheDir, branch);
|
|
console.log('Game updated successfully, patching will be forced on launch...');
|
|
|
|
if (progressCallback) {
|
|
progressCallback('Preparing game launch...', 90, null, null, null);
|
|
}
|
|
await new Promise(resolve => setTimeout(resolve, 3000));
|
|
|
|
} catch (updateError) {
|
|
console.error('Update failed:', updateError);
|
|
if (progressCallback) {
|
|
progressCallback(`Update failed: ${updateError.message}`, -1, null, null, null);
|
|
}
|
|
throw updateError;
|
|
}
|
|
}
|
|
|
|
if (progressCallback) {
|
|
progressCallback('Launching game...', 80, null, null, null);
|
|
}
|
|
|
|
const launchResult = await launchGame(playerName, progressCallback, javaPathOverride, installPathOverride, gpuPreference, branch);
|
|
|
|
// Ensure we always return a result
|
|
if (!launchResult) {
|
|
console.error('launchGame returned null/undefined, creating fallback response');
|
|
return { success: false, error: 'Game launch failed - no response from launcher' };
|
|
}
|
|
|
|
return launchResult;
|
|
} catch (error) {
|
|
console.error('Error in version check and launch:', error);
|
|
if (progressCallback) {
|
|
progressCallback(`Error: ${error.message}`, -1, null, null, null);
|
|
}
|
|
// Always return an error response instead of throwing
|
|
return { success: false, error: error.message || 'Unknown launch error' };
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
launchGame,
|
|
launchGameWithVersionCheck
|
|
}; |