Compare commits

..

1 Commits

Author SHA1 Message Date
Fazri Gading
ae375f9b6e Release Stable Build v2.1.1 (#197)
* fix: resolve cross-platform EPERM permissions errors

modManager.js:
- Switch from hardcoded 'junction' to dynamic symlink type based on OS (fixing Linux EPERM).
- Add retry logic for directory removal to handle file locking race conditions.
- Improve broken symlink detection during profile sync.

gameManager.js:
- Implement retry loop (3 attempts) for game directory removal in updateGameFiles to prevent EBUSY/EPERM errors on Windows.

paths.js:
- Prevent fs.mkdirSync failure in getModsPath by pre-checking for broken symbolic links.

* fix: missing pacman builds

* Update README.md Windows Prequisites for ARM64 builds

* fix: remove broken symlink after detected

* fix: add pathexists for paths.js to check symlink

* fix: isbrokenlink should be true to remove the symlink

* add arch package .pkg.tar.zst for release

* fix: release workflow for build-arch and build-linux

* build-arch job now only build arch .pkg.tar.zst package instead of the whole generic linux.
* build-linux job now exclude .pacman package since its deprecated and should not be used.

* fix: removes pacman build as it replaced by tar.zst and adds build:arch shortcut for pkgbuild

* aur: add proper VCS (-git) PKGBUILD

created clean VCS-based PKGBUILD following arch packaging conventions.

this explicitly marked as a rolling (-git) build and derives its version dynamically from git tags and commit history via pkgver(). previous hybrid approach has been changed.

key changes:
- use -git suffix to clearly indicate rolling source builds
- set pkgver=0 and compute the actual version via pkgver()
- build only a directory layout using electron-builder (--dir)
- avoid generating AppImage, deb, rpm, or pacman installers
- align build and package steps with Arch packaging guidelines

note: this PKGBUILD is intended for development and AUR use only and is not suitable for binary redistribution or release artifacts.

* ci: add fixed-version PKGBUILD for Arch Linux releases

this PKGBUILD intended for CI and GitHub release artifacts. targets tagged releases only and uses a fixed pkgver that matches the corresponding git tag. all of the VCS logic has been removed to PKGBUILD-git to ensure reproducible builds and stable versioning suitable for binary distribution.

the build process relies on electron-builder directory output (--dir) and packages only the unpacked application into a standard Arch Linux package (.pkg.tar.zst). other distro format are excluded from this path and handled separately.

this change establishes a clear separation between:
- rolling AUR development builds (-git)
- CI-generated, versioned Arch Linux release packages

the result is predictable artifact naming, correct version alignment, and Arch-compliant packaging for downstream users.

* Update README.md

adds information for Arch build

* Update README.md

BUILD.md location was changed and now this link is poiting to nothing

* Update PKGBUILD

* Update PKGBUILD-git

* chore: fix ubuntu/debian part in README.md

* Polish language support (#195)

* add hardware specification in support_request.yml

* Add logs text field in bug_report.yml

* chore: add changelog in README.md

* fix screenshot input in feature_request.yml

* add hardware spec input in bug_report.yml

---------

Co-authored-by: TalesAmaral <57869141+TalesAmaral@users.noreply.github.com>
Co-authored-by: walti0 <95646872+walti0@users.noreply.github.com>
2026-01-27 03:44:24 +08:00
9 changed files with 186 additions and 444 deletions

View File

@@ -14,15 +14,15 @@ source=("$url/archive/v$pkgver.tar.gz" "Hytale-F2P.desktop")
sha256sums=('SKIP' '46488fada4775d9976d7b7b62f8d1f1f8d9a9a9d8f8aa9af4f2e2153019f6a30')
build() {
cd "$pkgname-$pkgver"
cd "$_pkgname-$pkgver"
npm ci
npm run build:arch
}
package() {
cd "$pkgname-$pkgver"
install -d "$pkgdir/opt/$pkgname"
cp -r dist/linux-unpacked/* "$pkgdir/opt/$pkgname"
install -Dm644 "$srcdir/$pkgname.desktop" "$pkgdir/usr/share/applications/$pkgname.desktop"
install -Dm644 GUI/icon.png "$pkgdir/usr/share/icons/hicolor/256x256/apps/$pkgname.png"
cd "$_pkgname-$pkgver"
install -d "$pkgdir/opt/$_pkgname"
cp -r dist/linux-unpacked/* "$pkgdir/opt/$_pkgname"
install -Dm644 "$srcdir/$_pkgname.desktop" "$pkgdir/usr/share/applications/$_pkgname.desktop"
install -Dm644 GUI/icon.png "$pkgdir/usr/share/icons/hicolor/256x256/apps/$_pkgname.png"
}

View File

@@ -14,21 +14,6 @@ function getAppDir() {
}
}
/**
* Get centralized UserData saves directory (NEW in 2.1.2)
* UserData is now stored separately from game installation
*/
function getHytaleSavesDir() {
const home = os.homedir();
if (process.platform === 'win32') {
return path.join(home, 'AppData', 'Local', 'HytaleSaves');
} else if (process.platform === 'darwin') {
return path.join(home, 'Library', 'Application Support', 'HytaleSaves');
} else {
return path.join(home, '.hytalesaves');
}
}
const DEFAULT_APP_DIR = getAppDir();
function getResolvedAppDir(customPath) {
@@ -233,8 +218,20 @@ async function getModsPath(customInstallPath = null) {
function getProfilesDir(customInstallPath = null) {
try {
// NEW 2.1.2: Use centralized UserData location
const userDataPath = getHytaleSavesDir();
// get UserData path
let installPath = customInstallPath;
if (!installPath) {
const configFile = path.join(DEFAULT_APP_DIR, 'config.json');
if (fs.existsSync(configFile)) {
const config = JSON.parse(fs.readFileSync(configFile, 'utf8'));
installPath = config.installPath || '';
}
}
if (!installPath) installPath = getAppDir();
const branch = loadVersionBranch();
const gameLatest = path.join(installPath, branch, 'package', 'game', 'latest');
const userDataPath = findUserDataPath(gameLatest);
const profilesDir = path.join(userDataPath, 'Profiles');
if (!fs.existsSync(profilesDir)) {
@@ -250,7 +247,6 @@ function getProfilesDir(customInstallPath = null) {
module.exports = {
getAppDir,
getHytaleSavesDir,
getResolvedAppDir,
expandHome,
APP_DIR,

View File

@@ -12,7 +12,6 @@ const { resolveJavaPath, getJavaExec, getBundledJavaPath, detectSystemJava, JAVA
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;
@@ -107,9 +106,7 @@ async function launchGame(playerName = 'Player', progressCallback, javaPathOverr
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 userDataDir = path.join(customGameDir, 'Client', 'UserData');
const gameLatest = customGameDir;
let clientPath = findClientPath(gameLatest);
@@ -285,32 +282,6 @@ exec "$REAL_JAVA" "\${ARGS[@]}"
const gpuEnv = setupGpuEnvironment(gpuPreference);
Object.assign(env, gpuEnv);
// Linux memory allocator fixes for "free(): invalid pointer" crashes
// on Steam Deck (glibc 2.41) and Ubuntu LTS
if (process.platform === 'linux') {
// Option 1: Disable glibc heap validation
env.MALLOC_CHECK_ = '0';
// Option 2: Try to use jemalloc if available (more robust allocator)
// User can set HYTALE_USE_JEMALLOC=1 to enable
if (process.env.HYTALE_USE_JEMALLOC === '1') {
const jemalloc = require('fs').existsSync('/usr/lib/libjemalloc.so.2')
? '/usr/lib/libjemalloc.so.2'
: require('fs').existsSync('/usr/lib/x86_64-linux-gnu/libjemalloc.so.2')
? '/usr/lib/x86_64-linux-gnu/libjemalloc.so.2'
: null;
if (jemalloc) {
env.LD_PRELOAD = jemalloc + (env.LD_PRELOAD ? ':' + env.LD_PRELOAD : '');
console.log(`Linux: Using jemalloc allocator (${jemalloc})`);
} else {
console.log('Linux: jemalloc not found, using glibc with MALLOC_CHECK_=0');
}
} else {
console.log('Linux: Using glibc with MALLOC_CHECK_=0 (set HYTALE_USE_JEMALLOC=1 to try jemalloc)');
}
}
try {
let spawnOptions = {
stdio: ['ignore', 'pipe', 'pipe'],

View File

@@ -9,7 +9,7 @@ const { installButler } = require('./butlerManager');
const { downloadAndReplaceHomePageUI, downloadAndReplaceLogo } = require('./uiFileManager');
const { saveUsername, saveInstallPath, loadJavaPath, CONFIG_FILE, loadConfig, loadVersionBranch, saveVersionClient, loadVersionClient } = require('../core/config');
const { resolveJavaPath, detectSystemJava, downloadJRE, getJavaExec, getBundledJavaPath } = require('./javaManager');
const { getUserDataPath, migrateUserDataToCentralized } = require('../utils/userDataMigration');
const userDataBackup = require('../utils/userDataBackup');
async function downloadPWR(branch = 'release', fileName = '4.pwr', progressCallback, cacheDir = CACHE_DIR, manualRetry = false) {
const osName = getOS();
@@ -308,25 +308,31 @@ async function applyPWR(pwrFile, progressCallback, gameDir = GAME_DIR, toolsDir
async function updateGameFiles(newVersion, progressCallback, gameDir = GAME_DIR, toolsDir = TOOLS_DIR, cacheDir = CACHE_DIR, branchOverride = null) {
let tempUpdateDir;
let backupPath = null;
const branch = branchOverride || loadVersionBranch();
const installPath = path.dirname(path.dirname(path.dirname(path.dirname(gameDir))));
// Vérifier si on a version_client et version_branch dans config.json
const config = loadConfig();
const oldBranch = config.version_branch || 'release';
const hasVersionConfig = !!(config.version_client && config.version_branch);
const oldBranch = config.version_branch || 'release'; // L'ancienne branche pour le backup
console.log(`[UpdateGameFiles] hasVersionConfig: ${hasVersionConfig}`);
console.log(`[UpdateGameFiles] Switching from ${oldBranch} to ${branch}`);
try {
// NEW 2.1.2: Ensure UserData migration to centralized location
if (progressCallback) {
progressCallback('Backing up user data...', 5, null, null, null);
}
// Backup UserData AVANT de télécharger/installer (critical for same-branch updates)
try {
console.log('[UpdateGameFiles] Ensuring UserData migration...');
const migrationResult = await migrateUserDataToCentralized();
if (migrationResult.migrated) {
console.log('[UpdateGameFiles] ✓ UserData migrated to centralized location');
} else if (migrationResult.alreadyMigrated) {
console.log('[UpdateGameFiles] ✓ UserData already in centralized location');
console.log(`[UpdateGameFiles] Attempting to backup UserData from old branch: ${oldBranch}`);
backupPath = await userDataBackup.backupUserData(installPath, oldBranch, hasVersionConfig);
if (backupPath) {
console.log(`[UpdateGameFiles] ✓ UserData backed up from ${oldBranch}: ${backupPath}`);
}
} catch (migrationError) {
console.warn('[UpdateGameFiles] UserData migration warning:', migrationError.message);
} catch (backupError) {
console.warn('[UpdateGameFiles] UserData backup failed:', backupError.message);
}
if (progressCallback) {
@@ -384,9 +390,31 @@ async function updateGameFiles(newVersion, progressCallback, gameDir = GAME_DIR,
const logoResult = await downloadAndReplaceLogo(gameDir, progressCallback);
console.log('Logo@2x.png update result after update:', logoResult);
// NEW 2.1.2: No longer create UserData in game installation
// UserData is now in centralized location (getUserDataPath())
console.log('[UpdateGameFiles] UserData is managed in centralized location');
// Ensure UserData directory exists
const userDataDir = path.join(gameDir, 'Client', 'UserData');
if (!fs.existsSync(userDataDir)) {
console.log(`[UpdateGameFiles] Creating UserData directory at: ${userDataDir}`);
fs.mkdirSync(userDataDir, { recursive: true });
}
if (progressCallback) {
progressCallback('Restoring user data...', 90, null, null, null);
}
// Restore UserData using new system
if (backupPath) {
try {
console.log(`[UpdateGameFiles] Restoring UserData from ${oldBranch} to ${branch}`);
console.log(`[UpdateGameFiles] Source backup: ${backupPath}`);
await userDataBackup.restoreUserData(backupPath, installPath, branch);
await userDataBackup.cleanupBackup(backupPath);
console.log(`[UpdateGameFiles] ✓ UserData migrated successfully from ${oldBranch} to ${branch}`);
} catch (restoreError) {
console.warn('[UpdateGameFiles] ✗ UserData restore failed:', restoreError.message);
}
} else {
console.log('[UpdateGameFiles] No backup to restore, empty UserData folder created');
}
console.log(`Game files updated successfully to version: ${newVersion}`);
@@ -406,6 +434,15 @@ async function updateGameFiles(newVersion, progressCallback, gameDir = GAME_DIR,
} catch (error) {
console.error('Error updating game files:', error);
if (backupPath) {
try {
await userDataBackup.cleanupBackup(backupPath);
console.log('UserData backup cleaned up after error');
} catch (cleanupError) {
console.warn('Could not clean up UserData backup:', cleanupError.message);
}
}
if (tempUpdateDir && fs.existsSync(tempUpdateDir)) {
fs.rmSync(tempUpdateDir, { recursive: true, force: true });
}
@@ -433,18 +470,28 @@ async function installGame(playerName = 'Player', progressCallback, javaPathOver
const customToolsDir = path.join(customAppDir, 'butler');
const customGameDir = path.join(customAppDir, branch, 'package', 'game', 'latest');
const customJreDir = path.join(customAppDir, branch, 'package', 'jre', 'latest');
const userDataDir = path.join(customGameDir, 'Client', 'UserData');
// Vérifier si on a version_client et version_branch dans config.json
const config = loadConfig();
const hasVersionConfig = !!(config.version_client && config.version_branch);
console.log(`[InstallGame] Configuration detected - version_client: ${config.version_client}, version_branch: ${config.version_branch}`);
console.log(`[InstallGame] hasVersionConfig: ${hasVersionConfig}`);
// Backup UserData AVANT l'installation si nécessaire
let backupPath = null;
if (progressCallback) {
progressCallback('Checking for existing UserData...', 5, null, null, null);
}
// NEW 2.1.2: Ensure UserData migration to centralized location
try {
console.log('[InstallGame] Ensuring UserData migration...');
const migrationResult = await migrateUserDataToCentralized();
if (migrationResult.migrated) {
console.log('[InstallGame] ✓ UserData migrated to centralized location');
} else if (migrationResult.alreadyMigrated) {
console.log('[InstallGame] ✓ UserData already in centralized location');
console.log(`[InstallGame] Attempting UserData backup (hasVersionConfig: ${hasVersionConfig})...`);
backupPath = await userDataBackup.backupUserData(customAppDir, branch, hasVersionConfig);
if (backupPath) {
console.log(`[InstallGame] ✓ UserData backed up to: ${backupPath}`);
}
} catch (migrationError) {
console.warn('[InstallGame] UserData migration warning:', migrationError.message);
} catch (backupError) {
console.warn('[InstallGame] UserData backup failed:', backupError.message);
}
[customAppDir, customCacheDir, customToolsDir].forEach(dir => {
@@ -453,6 +500,10 @@ async function installGame(playerName = 'Player', progressCallback, javaPathOver
}
});
if (!fs.existsSync(userDataDir)) {
fs.mkdirSync(userDataDir, { recursive: true });
}
saveUsername(playerName);
if (installPathOverride) {
saveInstallPath(installPathOverride);
@@ -544,9 +595,29 @@ async function installGame(playerName = 'Player', progressCallback, javaPathOver
const logoResult = await downloadAndReplaceLogo(customGameDir, progressCallback);
console.log('Logo@2x.png update result after installation:', logoResult);
// NEW 2.1.2: No longer create UserData in game installation
// UserData is managed in centralized location (getUserDataPath())
console.log('[InstallGame] UserData is managed in centralized location');
// Ensure UserData directory exists
if (!fs.existsSync(userDataDir)) {
console.log(`[InstallGame] Creating UserData directory at: ${userDataDir}`);
fs.mkdirSync(userDataDir, { recursive: true });
}
// Restore UserData from backup if exists
if (backupPath) {
if (progressCallback) {
progressCallback('Restoring UserData...', 95, null, null, null);
}
try {
console.log(`[InstallGame] Restoring UserData from: ${backupPath}`);
await userDataBackup.restoreUserData(backupPath, customAppDir, branch);
await userDataBackup.cleanupBackup(backupPath);
console.log('[InstallGame] ✓ UserData restored successfully');
} catch (restoreError) {
console.warn('[InstallGame] ✗ UserData restore failed:', restoreError.message);
}
} else {
console.log('[InstallGame] No backup to restore, empty UserData folder created');
}
if (progressCallback) {
progressCallback('Installation complete', 100, null, null, null);

View File

@@ -147,9 +147,8 @@ class ClientPatcher {
}
/**
* Replace bytes in buffer with null-padding for shorter replacements
* When new pattern is shorter than old, pads with 0x00 to prevent leftover bytes
* that can cause memory corruption (free(): invalid pointer) on some systems
* Replace bytes in buffer - only overwrites the length of new bytes
* Prevents offset corruption by not expanding the replacement
*/
replaceBytes(buffer, oldBytes, newBytes) {
let count = 0;
@@ -163,20 +162,7 @@ class ClientPatcher {
const positions = this.findAllOccurrences(result, oldBytes);
for (const pos of positions) {
// Log offset and surrounding bytes for debugging
const before = result.slice(Math.max(0, pos - 8), pos);
const after = result.slice(pos + oldBytes.length, Math.min(result.length, pos + oldBytes.length + 8));
console.log(` Patching at offset 0x${pos.toString(16)} (${pos})`);
console.log(` Before: ${before.toString('hex')}`);
console.log(` Old pattern: ${oldBytes.slice(0, 20).toString('hex')}${oldBytes.length > 20 ? '...' : ''}`);
console.log(` After: ${after.toString('hex')}`);
// First fill the entire old pattern region with zeros
// This prevents leftover bytes from causing memory corruption
if (newBytes.length < oldBytes.length) {
result.fill(0x00, pos, pos + oldBytes.length);
}
// Then write the new bytes
// Only overwrite the length of the new bytes
newBytes.copy(result, pos);
count++;
}
@@ -184,65 +170,6 @@ class ClientPatcher {
return { buffer: result, count };
}
/**
* Replace bytes with skip/limit control (for debugging)
* HYTALE_PATCH_SKIP: comma-separated indices to skip (e.g., "0,2" skips 1st and 3rd)
* HYTALE_PATCH_LIMIT: max number of patches to apply
*/
replaceBytesLimited(buffer, oldBytes, newBytes, limit) {
let count = 0;
const result = Buffer.from(buffer);
if (newBytes.length > oldBytes.length) {
console.warn(` Warning: New pattern (${newBytes.length}) longer than old (${oldBytes.length}), skipping`);
return { buffer: result, count: 0 };
}
// Parse skip list from env
const skipIndices = (process.env.HYTALE_PATCH_SKIP || '')
.split(',')
.filter(s => s.trim())
.map(s => parseInt(s.trim(), 10));
if (skipIndices.length > 0) {
console.log(` Skip indices: ${skipIndices.join(', ')}`);
}
const positions = this.findAllOccurrences(result, oldBytes);
let patchedCount = 0;
for (let i = 0; i < positions.length; i++) {
const pos = positions[i];
// Log offset and surrounding bytes for debugging
const before = result.slice(Math.max(0, pos - 8), pos);
const after = result.slice(pos + oldBytes.length, Math.min(result.length, pos + oldBytes.length + 8));
if (skipIndices.includes(i)) {
console.log(` [${i}] Skipping offset 0x${pos.toString(16)} (in skip list)`);
continue;
}
if (patchedCount >= limit) {
console.log(` [${i}] Skipping offset 0x${pos.toString(16)} (limit reached)`);
continue;
}
console.log(` [${i}] Patching at offset 0x${pos.toString(16)} (${pos})`);
console.log(` Before: ${before.toString('hex')}`);
console.log(` Old pattern: ${oldBytes.slice(0, 20).toString('hex')}${oldBytes.length > 20 ? '...' : ''}`);
console.log(` After: ${after.toString('hex')}`);
if (newBytes.length < oldBytes.length) {
result.fill(0x00, pos, pos + oldBytes.length);
}
newBytes.copy(result, pos);
patchedCount++;
count++;
}
return { buffer: result, count };
}
/**
* UTF-8 domain replacement for Java JAR files.
* Java stores strings in UTF-8 format in the constant pool.
@@ -270,19 +197,11 @@ class ClientPatcher {
* .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
*
* IMPORTANT: newDomain must be same length or shorter than oldDomain to avoid buffer overflow
*/
findAndReplaceDomainSmart(data, oldDomain, newDomain) {
let count = 0;
const result = Buffer.from(data);
// Safety check: new domain must not be longer than old
if (newDomain.length > oldDomain.length) {
console.warn(` Warning: New domain (${newDomain.length} chars) longer than old (${oldDomain.length} chars), skipping smart replacement`);
return { buffer: result, count: 0 };
}
const oldUtf16NoLast = this.stringToUtf16LE(oldDomain.slice(0, -1));
const newUtf16NoLast = this.stringToUtf16LE(newDomain.slice(0, -1));
@@ -298,11 +217,6 @@ class ClientPatcher {
const lastCharFirstByte = result[lastCharPos];
if (lastCharFirstByte === oldLastCharByte) {
// Zero-fill the old region first if new is shorter
if (newUtf16NoLast.length < oldUtf16NoLast.length) {
result.fill(0x00, pos, pos + oldUtf16NoLast.length);
}
newUtf16NoLast.copy(result, pos);
result[lastCharPos] = newLastCharByte;
@@ -325,10 +239,6 @@ class ClientPatcher {
/**
* Apply all domain patches using length-prefixed format
* This is the main patching method for variable-length domains
*
* Debug env vars:
* HYTALE_SKIP_SENTRY_PATCH=1 - Skip sentry URL patch (biggest size change)
* HYTALE_SKIP_SUBDOMAIN_PATCH=1 - Skip subdomain prefix patches
*/
applyDomainPatches(data, domain, protocol = 'https://') {
let result = Buffer.from(data);
@@ -337,79 +247,50 @@ class ClientPatcher {
console.log(` Patching strategy: ${strategy.description}`);
// 1. Patch telemetry/sentry URL (skip if debugging)
if (process.env.HYTALE_SKIP_SENTRY_PATCH === '1') {
console.log(` Skipping sentry patch (HYTALE_SKIP_SENTRY_PATCH=1)`);
} else {
const oldSentry = 'https://ca900df42fcf57d4dd8401a86ddd7da2@sentry.hytale.com/2';
const newSentry = `${protocol}t@${domain}/2`;
// 1. Patch telemetry/sentry URL
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 = sentryResult.buffer;
if (sentryResult.count > 0) {
console.log(` Replaced ${sentryResult.count} sentry occurrence(s)`);
totalCount += sentryResult.count;
}
console.log(` Patching sentry: ${oldSentry.slice(0, 30)}... -> ${newSentry}`);
const sentryResult = this.replaceBytes(
result,
this.stringToLengthPrefixed(oldSentry),
this.stringToLengthPrefixed(newSentry)
);
result = sentryResult.buffer;
if (sentryResult.count > 0) {
console.log(` Replaced ${sentryResult.count} sentry occurrence(s)`);
totalCount += sentryResult.count;
}
// 2. Patch main domain (hytale.com -> mainDomain)
// Try length-prefixed format first, then fall back to pure UTF-16LE
console.log(` Patching domain: ${ORIGINAL_DOMAIN} -> ${strategy.mainDomain}`);
// Check for HYTALE_PATCH_MODE env var to test different formats
const patchMode = process.env.HYTALE_PATCH_MODE || 'length-prefixed';
console.log(` Patch mode: ${patchMode}`);
let domainResult;
if (patchMode === 'utf16le') {
// Pure UTF-16LE replacement (no length prefix)
const oldUtf16 = this.stringToUtf16LE(ORIGINAL_DOMAIN);
const newUtf16 = this.stringToUtf16LE(strategy.mainDomain);
console.log(` UTF-16LE: old=${oldUtf16.length} bytes, new=${newUtf16.length} bytes`);
// HYTALE_PATCH_LIMIT: only patch first N occurrences (for debugging)
const patchLimit = parseInt(process.env.HYTALE_PATCH_LIMIT || '999', 10);
console.log(` Patch limit: ${patchLimit}`);
domainResult = this.replaceBytesLimited(result, oldUtf16, newUtf16, patchLimit);
} else {
// Length-prefixed format (default)
domainResult = this.replaceBytes(
result,
this.stringToLengthPrefixed(ORIGINAL_DOMAIN),
this.stringToLengthPrefixed(strategy.mainDomain)
);
}
const domainResult = this.replaceBytes(
result,
this.stringToLengthPrefixed(ORIGINAL_DOMAIN),
this.stringToLengthPrefixed(strategy.mainDomain)
);
result = domainResult.buffer;
if (domainResult.count > 0) {
console.log(` Replaced ${domainResult.count} domain occurrence(s)`);
totalCount += domainResult.count;
}
// 3. Patch subdomain prefixes (skip if debugging)
if (process.env.HYTALE_SKIP_SUBDOMAIN_PATCH === '1') {
console.log(` Skipping subdomain patches (HYTALE_SKIP_SUBDOMAIN_PATCH=1)`);
} else {
const subdomains = ['https://tools.', 'https://sessions.', 'https://account-data.', 'https://telemetry.'];
const newSubdomainPrefix = protocol + strategy.subdomainPrefix;
// 3. Patch subdomain prefixes
const subdomains = ['https://tools.', 'https://sessions.', 'https://account-data.', 'https://telemetry.'];
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 = subResult.buffer;
if (subResult.count > 0) {
console.log(` Replaced ${subResult.count} occurrence(s)`);
totalCount += subResult.count;
}
for (const sub of subdomains) {
console.log(` Patching subdomain: ${sub} -> ${newSubdomainPrefix}`);
const subResult = this.replaceBytes(
result,
this.stringToLengthPrefixed(sub),
this.stringToLengthPrefixed(newSubdomainPrefix)
);
result = subResult.buffer;
if (subResult.count > 0) {
console.log(` Replaced ${subResult.count} occurrence(s)`);
totalCount += subResult.count;
}
}
@@ -417,13 +298,38 @@ class ClientPatcher {
}
/**
* Patch Discord invite URLs - DISABLED
* Was causing buffer overflow crashes on Steam Deck/Ubuntu LTS
* The Discord URL in the game doesn't affect F2P functionality anyway
* Patch Discord invite URLs from .gg/hytale to .gg/MHkEjepMQ7
*/
patchDiscordUrl(data) {
// Disabled - no practical effect and was causing memory corruption
return { buffer: Buffer.from(data), count: 0 };
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 };
}
/**
@@ -577,14 +483,6 @@ class ClientPatcher {
progressCallback('Patching domain references...', 50);
}
// HYTALE_NOOP_TEST: Just read and write binary without any changes
if (process.env.HYTALE_NOOP_TEST === '1') {
console.log('NOOP TEST: Writing binary without modifications...');
fs.writeFileSync(clientPath, data);
this.markAsPatched(clientPath);
return { success: true, patchCount: 0, noop: true };
}
console.log('Applying domain patches (length-prefixed format)...');
const { buffer: patchedData, count } = this.applyDomainPatches(data, newDomain);
@@ -592,17 +490,6 @@ class ClientPatcher {
const { buffer: finalData, count: discordCount } = this.patchDiscordUrl(patchedData);
if (count === 0 && discordCount === 0) {
// Check if we're in debug mode with skip - don't fallback if intentionally skipping
const hasSkipList = (process.env.HYTALE_PATCH_SKIP || '').trim().length > 0;
const noLegacyFallback = process.env.HYTALE_NO_LEGACY_FALLBACK === '1';
if (hasSkipList || noLegacyFallback) {
console.log('No occurrences patched (skip list active or legacy fallback disabled)');
fs.writeFileSync(clientPath, patchedData);
this.markAsPatched(clientPath);
return { success: true, patchCount: 0, skipped: true };
}
console.log('No occurrences found - trying legacy UTF-16LE format...');
// Fallback to legacy patching for older binary formats

View File

@@ -46,8 +46,7 @@ class UserDataBackup {
console.log(`[UserDataBackup] Copying from ${userDataPath} to ${backupPath}...`);
await fs.copy(userDataPath, backupPath, {
overwrite: true,
errorOnExist: false,
dereference: true // Follow symlinks to avoid EPERM errors on Windows
errorOnExist: false
});
console.log('[UserDataBackup] ✓ Backup completed successfully');
return backupPath;
@@ -83,8 +82,7 @@ class UserDataBackup {
await fs.copy(backupPath, userDataPath, {
overwrite: true,
errorOnExist: false,
dereference: true // Follow symlinks to avoid EPERM errors on Windows
errorOnExist: false
});
console.log('UserData restore completed successfully');

View File

@@ -1,172 +0,0 @@
const fs = require('fs-extra');
const path = require('path');
const { getHytaleSavesDir, getResolvedAppDir } = require('../core/paths');
const { loadConfig, saveConfig } = require('../core/config');
/**
* NEW SYSTEM (2.1.2+): UserData Migration to Centralized Location
*
* UserData is now stored in a centralized location instead of inside game installation:
* - Windows: %LOCALAPPDATA%\HytaleSaves\
* - macOS: ~/Library/Application Support/HytaleSaves/
* - Linux: ~/.hytalesaves/
*
* This eliminates the need for backup/restore during updates.
*/
/**
* Check if migration to centralized UserData has been completed
*/
function isMigrationCompleted() {
const config = loadConfig();
return config.userDataMigrated === true;
}
/**
* Mark migration as completed
*/
function markMigrationCompleted() {
saveConfig({ userDataMigrated: true });
console.log('[UserDataMigration] Migration marked as completed in config');
}
/**
* Find old UserData location (pre-2.1.2)
* Searches in: installPath/branch/package/game/latest/Client/UserData
*/
function findOldUserDataPath() {
try {
const config = loadConfig();
const installPath = getResolvedAppDir();
const branch = config.version_branch || 'release';
console.log(`[UserDataMigration] Looking for old UserData...`);
console.log(`[UserDataMigration] Install path: ${installPath}`);
console.log(`[UserDataMigration] Branch: ${branch}`);
// Old location
const oldPath = path.join(installPath, branch, 'package', 'game', 'latest', 'Client', 'UserData');
console.log(`[UserDataMigration] Checking: ${oldPath}`);
console.log(`[UserDataMigration] Checking: ${oldPath}`);
if (fs.existsSync(oldPath)) {
console.log(`[UserDataMigration] ✓ Found old UserData at: ${oldPath}`);
return oldPath;
}
console.log(`[UserDataMigration] ✗ Not found at current branch location`);
// Try other branch if current doesn't exist
const otherBranch = branch === 'release' ? 'pre-release' : 'release';
const otherPath = path.join(installPath, otherBranch, 'package', 'game', 'latest', 'Client', 'UserData');
console.log(`[UserDataMigration] Checking other branch: ${otherPath}`);
console.log(`[UserDataMigration] Checking other branch: ${otherPath}`);
if (fs.existsSync(otherPath)) {
console.log(`[UserDataMigration] ✓ Found old UserData in other branch at: ${otherPath}`);
return otherPath;
}
console.log('[UserDataMigration] ✗ No old UserData found in any branch');
return null;
} catch (error) {
console.error('[UserDataMigration] Error finding old UserData:', error);
return null;
}
}
/**
* Migrate UserData from old location to new centralized location
* One-time operation when upgrading to 2.1.2
*/
async function migrateUserDataToCentralized() {
// Check if already migrated
if (isMigrationCompleted()) {
console.log('[UserDataMigration] Migration already completed, skipping');
return { success: true, alreadyMigrated: true };
}
console.log('[UserDataMigration] === Starting UserData Migration to Centralized Location ===');
const newUserDataPath = getHytaleSavesDir();
console.log(`[UserDataMigration] Target location: ${newUserDataPath}`);
// Ensure new directory exists
if (!fs.existsSync(newUserDataPath)) {
fs.mkdirSync(newUserDataPath, { recursive: true });
console.log('[UserDataMigration] Created new HytaleSaves directory');
}
// Find old UserData
const oldUserDataPath = findOldUserDataPath();
if (!oldUserDataPath) {
console.log('[UserDataMigration] No old UserData found - fresh install or already migrated');
// Don't mark as migrated - let it check again next time in case game gets installed later
return { success: true, freshInstall: true };
}
// Check if new location already has data (shouldn't happen, but safety check)
const existingFiles = fs.readdirSync(newUserDataPath);
if (existingFiles.length > 0) {
console.warn('[UserDataMigration] New location already contains files, marking as migrated to avoid re-attempts');
markMigrationCompleted();
return { success: true, skipped: true, reason: 'target_not_empty' };
}
try {
console.log(`[UserDataMigration] Copying from ${oldUserDataPath} to ${newUserDataPath}...`);
// Copy all UserData to new location
await fs.copy(oldUserDataPath, newUserDataPath, {
overwrite: false,
errorOnExist: false,
dereference: true // Follow symlinks to avoid EPERM errors on Windows
});
console.log('[UserDataMigration] ✓ UserData copied successfully');
// Mark migration as completed
markMigrationCompleted();
console.log('[UserDataMigration] === Migration Completed Successfully ===');
return {
success: true,
migrated: true,
from: oldUserDataPath,
to: newUserDataPath
};
} catch (error) {
console.error('[UserDataMigration] ✗ Migration failed:', error);
return {
success: false,
error: error.message,
from: oldUserDataPath,
to: newUserDataPath
};
}
}
/**
* Get the centralized UserData path (always use this in 2.1.2+)
* Ensures directory exists
*/
function getUserDataPath() {
const userDataPath = getHytaleSavesDir();
// Ensure directory exists
if (!fs.existsSync(userDataPath)) {
fs.mkdirSync(userDataPath, { recursive: true });
console.log(`[UserDataMigration] Created UserData directory: ${userDataPath}`);
}
return userDataPath;
}
module.exports = {
migrateUserDataToCentralized,
getUserDataPath,
isMigrationCompleted,
findOldUserDataPath
};

View File

@@ -5,7 +5,6 @@ const { autoUpdater } = require('electron-updater');
const fs = require('fs');
const { launchGame, launchGameWithVersionCheck, installGame, saveUsername, loadUsername, saveChatUsername, loadChatUsername, saveChatColor, loadChatColor, saveJavaPath, loadJavaPath, saveInstallPath, loadInstallPath, saveDiscordRPC, loadDiscordRPC, saveLanguage, loadLanguage, saveCloseLauncherOnStart, loadCloseLauncherOnStart, saveLauncherHardwareAcceleration, loadLauncherHardwareAcceleration, isGameInstalled, uninstallGame, repairGame, getHytaleNews, handleFirstLaunchCheck, proposeGameUpdate, markAsLaunched } = require('./backend/launcher');
const { retryPWRDownload } = require('./backend/managers/gameManager');
const { migrateUserDataToCentralized } = require('./backend/utils/userDataMigration');
// Handle Hardware Acceleration
try {
@@ -299,14 +298,6 @@ app.whenReady().then(async () => {
// Initialize Profile Manager (runs migration if needed)
profileManager.init();
// Migrate UserData to centralized location (v2.1.2+)
console.log('[Startup] Checking UserData migration...');
try {
await migrateUserDataToCentralized();
} catch (error) {
console.error('[Startup] UserData migration failed:', error);
}
createSplashScreen();
setTimeout(async () => {

View File

@@ -1,6 +1,6 @@
{
"name": "hytale-f2p-launcher",
"version": "2.1.2",
"version": "2.1.1",
"description": "A modern, cross-platform launcher for Hytale with automatic updates and multi-client support",
"homepage": "https://github.com/amiayweb/Hytale-F2P",
"main": "main.js",