Compare commits

..

14 Commits

Author SHA1 Message Date
sanasol
dfe9ed2a89 ci: set 6-hour max timeout for macOS notarization
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 20:59:28 +01:00
sanasol
0aaf74a3db fix: add verbose logging to notarize script for debugging
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 15:14:59 +01:00
sanasol
be78f67439 chore: update package-lock.json
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 15:04:13 +01:00
sanasol
d0b9ae1da8 ci: separate macOS release from main release job
macOS notarization is slow (5-10 min). Now release is created
immediately when Windows/Linux/Arch complete, and macOS uploads
to the same release when notarization finishes.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 15:01:57 +01:00
sanasol
e8105cb30e feat: add macOS code signing and notarization support
- Add entitlements.mac.plist for hardened runtime
- Add notarize.js post-sign hook for Apple notarization
- Update package.json with signing config and @electron/notarize dep
- Update GitHub Actions workflow with signing secrets

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 14:48:40 +01:00
AMIAY
79456e43a6 Merge pull request #213 from amiayweb/fix/update-system-improvements 2026-01-28 03:14:05 +01:00
sanasol
dd2dbc6f08 fix: improve update system UX and macOS compatibility
Update System Improvements:
- Fix duplicate update popups by disabling legacy updater.js
- Add skip button to update popup (shows after 30s, on error, or after download)
- Add macOS-specific handling with manual download as primary option
- Add missing open-download-page IPC handler
- Add missing unblockInterface() method to properly clean up after popup close
- Add quitAndInstallUpdate alias in preload for compatibility
- Remove pulse animation when download completes
- Fix manual download button to show correct status and close popup
- Sync player name to settings input after first install

Client Patcher Cleanup:
- Remove server patching code (server uses pre-patched JAR from CDN)
- Simplify to client-only patching
- Remove unused imports (crypto, AdmZip, execSync, spawn, javaManager)
- Remove unused methods (stringToUtf8, findAndReplaceDomainUtf8)
- Move localhost dev code to backup file for reference

Code Quality Fixes:
- Fix duplicate DOMContentLoaded handlers in install.js
- Fix duplicate checkForUpdates definition in preload.js
- Fix redundant if/else in onProgressUpdate callback
- Fix typo "Harwadre" -> "Hardware" in preload.js

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 01:48:58 +01:00
Fazri Gading
c4acb32fcd Update support_request.yml 2026-01-28 05:16:00 +08:00
Fazri Gading
fbcbafb9b5 chore: remove Windows and Linux ARM64 information on the README.md 2026-01-28 04:26:42 +08:00
Terromur
86ed33358c Merge pull request #210 from amiayweb/fix/steamdeck-libzstd
fix: Steam Deck/Ubuntu crash - use system libzstd.so
2026-01-27 23:51:04 +05:00
sanasol
9ec97f9d33 fix: Steam Deck/Ubuntu crash - use system libzstd.so
The bundled libzstd.so is incompatible with glibc 2.41's stricter heap
validation, causing "free(): invalid pointer" crashes.

Solution: Automatically replace bundled libzstd.so with system version
on Linux. The launcher detects and symlinks to /usr/lib/libzstd.so.1.

- Auto-detect system libzstd at common paths (Arch, Debian, Fedora)
- Backup bundled version as libzstd.so.bundled
- Create symlink to system version
- Add HYTALE_NO_LIBZSTD_FIX=1 to disable if needed

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 19:40:42 +01:00
Fazri Gading
ee18455b4b chore: add downloads counter in README.md 2026-01-27 21:14:53 +08:00
Fazri Gading
a5c931b26d chore: add offline-mode warning to the README.md 2026-01-27 18:45:55 +08:00
Fazri Gading
661a0c9eed Update README.md 2026-01-27 17:38:33 +08:00
16 changed files with 918 additions and 592 deletions

View File

@@ -1,8 +1,22 @@
name: Support Request name: Support Request
description: Request help or support description: Request help or support
title: "[SUPPORT] " title: "[SUPPORT] <ADD YOUR TITLE HERE>"
labels: ["support"] labels: ["support"]
body: body:
- type: dropdown
id: acknowledge
attributes:
label: Checklist
options:
- label: I have read the README.md before asking Support Request.
required: true
- label: I have read the TROUBLESHOOTING.md before asking Support Request.
required: true
- label: I have added title before submitting this Support Request.
required: true
- label: I acknowledge that my Support Request will not be responded as quick as in Discord Open-A-Ticket, I prefer this way.
required: true
- type: markdown - type: markdown
attributes: attributes:
value: | value: |
@@ -24,7 +38,7 @@ body:
attributes: attributes:
label: Context label: Context
description: Provide any relevant context or background information. description: Provide any relevant context or background information.
placeholder: "I've tried..., but got..." placeholder: "I've tried these steps, but got..."
validations: validations:
required: true required: true
@@ -37,12 +51,17 @@ body:
validations: validations:
required: true required: true
- type: input - type: dropdown
id: version id: version
attributes: attributes:
label: Version label: Version
description: What version are you using? description: What version are you using?
placeholder: "e.g. v2.0.11 stable/pre-release" options:
- v2.1.2
- v2.1.1
- v2.1.0
- v2.0.11
- v2.0.2
validations: validations:
required: true required: true
@@ -52,13 +71,12 @@ body:
label: Platform label: Platform
description: What platform are you using? description: What platform are you using?
options: options:
- Windows 10 - Windows 11 x64
- Windows 11 - Windows 10 x64
- macOS (Apple Silicon) - macOS ARM64 (Apple Silicon)
- macOS (Intel) - Linux x64 Ubuntu/Debian-based
- Linux Ubuntu/Debian-based - Linux x64 Fedora/RHEL-based
- Linux Fedora/RHEL-based - Linux x64 Arch-based
- Linux Arch-based
validations: validations:
required: true required: true

View File

@@ -31,6 +31,7 @@ jobs:
build-macos: build-macos:
runs-on: macos-latest runs-on: macos-latest
timeout-minutes: 360 # Max allowed (6 hours) for notarization
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
@@ -40,6 +41,14 @@ jobs:
- run: npm ci - run: npm ci
- name: Build macOS Packages - name: Build macOS Packages
env:
# Code signing
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
# Notarization
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: npx electron-builder --mac --publish never run: npx electron-builder --mac --publish never
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
with: with:
@@ -123,8 +132,9 @@ jobs:
*.src.tar.zst *.src.tar.zst
.SRCINFO .SRCINFO
# Create release with Windows, Linux, Arch (fast builds)
release: release:
needs: [build-windows, build-macos, build-linux, build-arch] needs: [build-windows, build-linux, build-arch]
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: | if: |
startsWith(github.ref, 'refs/tags/v') || startsWith(github.ref, 'refs/tags/v') ||
@@ -135,14 +145,26 @@ jobs:
contents: write contents: write
steps: steps:
# FIX: './package.json' Module Not Found in `Get version` step
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Download all artifacts - name: Download Windows artifacts
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
with: with:
path: artifacts name: windows-builds
path: artifacts/windows-builds
- name: Download Linux artifacts
uses: actions/download-artifact@v4
with:
name: linux-builds
path: artifacts/linux-builds
- name: Download Arch artifacts
uses: actions/download-artifact@v4
with:
name: arch-package
path: artifacts/arch-package
- name: Display structure of downloaded files - name: Display structure of downloaded files
run: ls -R artifacts run: ls -R artifacts
@@ -155,18 +177,43 @@ jobs:
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
tag_name: ${{ github.ref_name }} tag_name: ${{ github.ref_name }}
# If it's a tag, use the tag.
# tag_name: ${{ github.ref_type == 'tag' && github.ref_name || format('v{0}.r{1}', steps.pkg_version.outputs.VERSION, github.run_number) }}
# If it's the 'release' branch, use 'v2.0.2-beta.r42'
# name: ${{ github.ref_type == 'tag' && github.ref_name || format('v{0}-beta.r{1}', steps.pkg_version.outputs.VERSION, github.run_number) }}
files: | files: |
artifacts/arch-package/*.pkg.tar.zst artifacts/arch-package/*.pkg.tar.zst
artifacts/arch-package/*.src.tar.zst artifacts/arch-package/*.src.tar.zst
artifacts/arch-package/.SRCINFO artifacts/arch-package/.SRCINFO
artifacts/linux-builds/**/* artifacts/linux-builds/*
artifacts/windows-builds/**/* artifacts/windows-builds/*
artifacts/macos-builds/**/*
generate_release_notes: true generate_release_notes: true
draft: true draft: true
prerelease: false prerelease: false
# Upload macOS builds separately (slow due to notarization)
release-macos:
needs: [build-macos, release]
runs-on: ubuntu-latest
if: |
startsWith(github.ref, 'refs/tags/v') ||
github.ref == 'refs/heads/main' ||
github.event_name == 'workflow_dispatch'
permissions:
contents: write
steps:
- name: Download macOS artifacts
uses: actions/download-artifact@v4
with:
name: macos-builds
path: artifacts/macos-builds
- name: Display macOS files
run: ls -R artifacts
- name: Upload macOS to Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
files: |
artifacts/macos-builds/*
draft: true
prerelease: false

View File

@@ -882,7 +882,7 @@
<script src="js/i18n.js"></script> <script src="js/i18n.js"></script>
<script type="module" src="js/settings.js"></script> <script type="module" src="js/settings.js"></script>
<script type="module" src="js/update.js"></script> <script type="module" src="js/update.js"></script>
<script src="js/updater.js"></script> <!-- updater.js disabled - using update.js instead which has skip button and macOS handling -->
</body> </body>

View File

@@ -72,8 +72,11 @@ export async function installGame() {
setTimeout(() => { setTimeout(() => {
window.LauncherUI.hideProgress(); window.LauncherUI.hideProgress();
window.LauncherUI.showLauncherOrInstall(true); window.LauncherUI.showLauncherOrInstall(true);
// Sync player name to both launcher and settings inputs
const playerNameInput = document.getElementById('playerName'); const playerNameInput = document.getElementById('playerName');
if (playerNameInput) playerNameInput.value = playerName; if (playerNameInput) playerNameInput.value = playerName;
const settingsPlayerName = document.getElementById('settingsPlayerName');
if (settingsPlayerName) settingsPlayerName.value = playerName;
resetInstallButton(); resetInstallButton();
}, 2000); }, 2000);
} }
@@ -125,8 +128,11 @@ function simulateInstallation(playerName) {
setTimeout(() => { setTimeout(() => {
window.LauncherUI.hideProgress(); window.LauncherUI.hideProgress();
window.LauncherUI.showLauncherOrInstall(true); window.LauncherUI.showLauncherOrInstall(true);
// Sync player name to both launcher and settings inputs
const playerNameInput = document.getElementById('playerName'); const playerNameInput = document.getElementById('playerName');
if (playerNameInput) playerNameInput.value = playerName; if (playerNameInput) playerNameInput.value = playerName;
const settingsPlayerName = document.getElementById('settingsPlayerName');
if (settingsPlayerName) settingsPlayerName.value = playerName;
resetInstallButton(); resetInstallButton();
}, 2000); }, 2000);
} }
@@ -246,9 +252,3 @@ document.addEventListener('DOMContentLoaded', async () => {
setupInstallation(); setupInstallation();
await checkGameStatusAndShowInterface(); await checkGameStatusAndShowInterface();
}); });
window.browseInstallPath = browseInstallPath;
document.addEventListener('DOMContentLoaded', async () => {
setupInstallation();
await checkGameStatusAndShowInterface();
});

View File

@@ -6,12 +6,12 @@ class ClientUpdateManager {
} }
init() { init() {
window.electronAPI.onUpdatePopup((updateInfo) => { console.log('🔧 ClientUpdateManager initializing...');
this.showUpdatePopup(updateInfo);
});
// Listen for electron-updater events // Listen for electron-updater events from main.js
// This is the primary update trigger - main.js checks for updates on startup
window.electronAPI.onUpdateAvailable((updateInfo) => { window.electronAPI.onUpdateAvailable((updateInfo) => {
console.log('📥 update-available event received:', updateInfo);
this.showUpdatePopup(updateInfo); this.showUpdatePopup(updateInfo);
}); });
@@ -20,18 +20,30 @@ class ClientUpdateManager {
}); });
window.electronAPI.onUpdateDownloaded((updateInfo) => { window.electronAPI.onUpdateDownloaded((updateInfo) => {
console.log('📦 update-downloaded event received:', updateInfo);
this.showUpdateDownloaded(updateInfo); this.showUpdateDownloaded(updateInfo);
}); });
window.electronAPI.onUpdateError((errorInfo) => { window.electronAPI.onUpdateError((errorInfo) => {
console.log('❌ update-error event received:', errorInfo);
this.handleUpdateError(errorInfo); this.handleUpdateError(errorInfo);
}); });
this.checkForUpdatesOnDemand(); console.log('✅ ClientUpdateManager initialized');
// Note: Don't call checkForUpdatesOnDemand() here - main.js already checks
// for updates after 3 seconds and sends 'update-available' event.
// Calling it here would cause duplicate popups.
} }
showUpdatePopup(updateInfo) { showUpdatePopup(updateInfo) {
if (this.updatePopupVisible) return; console.log('🔔 showUpdatePopup called, updatePopupVisible:', this.updatePopupVisible);
// Check if popup already exists in DOM (extra safety)
if (this.updatePopupVisible || document.getElementById('update-popup-overlay')) {
console.log('⚠️ Update popup already visible, skipping');
return;
}
this.updatePopupVisible = true; this.updatePopupVisible = true;
@@ -92,7 +104,10 @@ class ClientUpdateManager {
</div> </div>
<div class="update-popup-footer"> <div class="update-popup-footer">
This popup cannot be closed until you update the launcher <span id="update-footer-text">Downloading update...</span>
<button id="update-skip-btn" class="update-skip-btn" style="display: none; margin-top: 0.5rem; background: transparent; border: 1px solid rgba(255,255,255,0.2); color: #9ca3af; padding: 0.5rem 1rem; border-radius: 0.25rem; cursor: pointer; font-size: 0.75rem;">
Skip for now (not recommended)
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -119,10 +134,37 @@ class ClientUpdateManager {
try { try {
await window.electronAPI.quitAndInstallUpdate(); await window.electronAPI.quitAndInstallUpdate();
// If we're still here after 5 seconds, the install probably failed
setTimeout(() => {
console.log('⚠️ Install may have failed - showing skip option');
installBtn.disabled = false;
installBtn.innerHTML = '<i class="fas fa-check" style="margin-right: 0.5rem;"></i>Try Again';
// Show skip button
const skipBtn = document.getElementById('update-skip-btn');
const footerText = document.getElementById('update-footer-text');
if (skipBtn) {
skipBtn.style.display = 'inline-block';
if (footerText) {
footerText.textContent = 'Install not working? Skip for now:';
}
}
}, 5000);
} catch (error) { } catch (error) {
console.error('❌ Error installing update:', error); console.error('❌ Error installing update:', error);
installBtn.disabled = false; installBtn.disabled = false;
installBtn.innerHTML = '<i class="fas fa-check" style="margin-right: 0.5rem;"></i>Install & Restart'; installBtn.innerHTML = '<i class="fas fa-check" style="margin-right: 0.5rem;"></i>Install & Restart';
// Show skip button on error
const skipBtn = document.getElementById('update-skip-btn');
const footerText = document.getElementById('update-footer-text');
if (skipBtn) {
skipBtn.style.display = 'inline-block';
if (footerText) {
footerText.textContent = 'Install failed. Skip for now:';
}
}
} }
}); });
} }
@@ -138,9 +180,14 @@ class ClientUpdateManager {
try { try {
await window.electronAPI.openDownloadPage(); await window.electronAPI.openDownloadPage();
console.log('✅ Download page opened, launcher will close...'); console.log('✅ Download page opened');
downloadBtn.innerHTML = '<i class="fas fa-check" style="margin-right: 0.5rem;"></i>Launcher closing...'; downloadBtn.innerHTML = '<i class="fas fa-check" style="margin-right: 0.5rem;"></i>Opened in browser';
// Close the popup after opening download page
setTimeout(() => {
this.closeUpdatePopup();
}, 1500);
} catch (error) { } catch (error) {
console.error('❌ Error opening download page:', error); console.error('❌ Error opening download page:', error);
@@ -161,9 +208,39 @@ class ClientUpdateManager {
}); });
} }
// Show skip button after 30 seconds as fallback (in case update is stuck)
setTimeout(() => {
const skipBtn = document.getElementById('update-skip-btn');
const footerText = document.getElementById('update-footer-text');
if (skipBtn) {
skipBtn.style.display = 'inline-block';
if (footerText) {
footerText.textContent = 'Update taking too long?';
}
}
}, 30000);
const skipBtn = document.getElementById('update-skip-btn');
if (skipBtn) {
skipBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
this.closeUpdatePopup();
});
}
console.log('🔔 Update popup displayed with new style'); console.log('🔔 Update popup displayed with new style');
} }
closeUpdatePopup() {
const overlay = document.getElementById('update-popup-overlay');
if (overlay) {
overlay.remove();
}
this.updatePopupVisible = false;
this.unblockInterface();
}
updateDownloadProgress(progress) { updateDownloadProgress(progress) {
const progressBar = document.getElementById('update-progress-bar'); const progressBar = document.getElementById('update-progress-bar');
const progressPercent = document.getElementById('update-progress-percent'); const progressPercent = document.getElementById('update-progress-percent');
@@ -197,25 +274,86 @@ class ClientUpdateManager {
const statusText = document.getElementById('update-status-text'); const statusText = document.getElementById('update-status-text');
const progressContainer = document.getElementById('update-progress-container'); const progressContainer = document.getElementById('update-progress-container');
const buttonsContainer = document.getElementById('update-buttons-container'); const buttonsContainer = document.getElementById('update-buttons-container');
const installBtn = document.getElementById('update-install-btn');
const downloadBtn = document.getElementById('update-download-btn');
const skipBtn = document.getElementById('update-skip-btn');
const footerText = document.getElementById('update-footer-text');
const popupContainer = document.querySelector('.update-popup-container');
if (statusText) { // Remove breathing/pulse animation when download is complete
statusText.textContent = 'Update downloaded! Ready to install.'; if (popupContainer) {
popupContainer.classList.remove('update-popup-pulse');
} }
if (progressContainer) { if (progressContainer) {
progressContainer.style.display = 'none'; progressContainer.style.display = 'none';
} }
// Use platform info from main process if available, fallback to browser detection
const autoInstallSupported = updateInfo.autoInstallSupported !== undefined
? updateInfo.autoInstallSupported
: navigator.platform.toUpperCase().indexOf('MAC') < 0;
if (!autoInstallSupported) {
// macOS: Show manual download as primary since auto-update doesn't work
if (statusText) {
statusText.textContent = 'Update downloaded but auto-install may not work on macOS.';
}
if (installBtn) {
// Still show install button but as secondary option
installBtn.classList.add('update-download-btn-secondary');
installBtn.innerHTML = '<i class="fas fa-check" style="margin-right: 0.5rem;"></i>Try Install & Restart';
}
if (downloadBtn) {
// Make manual download primary
downloadBtn.classList.remove('update-download-btn-secondary');
downloadBtn.innerHTML = '<i class="fas fa-external-link-alt" style="margin-right: 0.5rem;"></i>Download Manually (Recommended)';
}
if (footerText) {
footerText.textContent = 'Auto-install often fails on macOS:';
}
} else {
// Windows/Linux: Auto-install should work
if (statusText) {
statusText.textContent = 'Update downloaded! Ready to install.';
}
if (footerText) {
footerText.textContent = 'Click to install the update:';
}
}
if (buttonsContainer) { if (buttonsContainer) {
buttonsContainer.style.display = 'block'; buttonsContainer.style.display = 'block';
} }
console.log('✅ Update downloaded, ready to install'); // Always show skip button in downloaded state
if (skipBtn) {
skipBtn.style.display = 'inline-block';
console.log('✅ Skip button made visible');
} else {
console.error('❌ Skip button not found in DOM!');
}
console.log('✅ Update downloaded, ready to install. autoInstallSupported:', autoInstallSupported);
} }
handleUpdateError(errorInfo) { handleUpdateError(errorInfo) {
console.error('Update error:', errorInfo); console.error('Update error:', errorInfo);
// Show skip button immediately on any error
const skipBtn = document.getElementById('update-skip-btn');
const footerText = document.getElementById('update-footer-text');
if (skipBtn) {
skipBtn.style.display = 'inline-block';
if (footerText) {
footerText.textContent = 'Update failed. You can skip for now.';
}
}
// If manual download is required, update the UI (this will handle status text) // If manual download is required, update the UI (this will handle status text)
if (errorInfo.requiresManualDownload) { if (errorInfo.requiresManualDownload) {
this.showManualDownloadRequired(errorInfo); this.showManualDownloadRequired(errorInfo);
@@ -289,6 +427,16 @@ class ClientUpdateManager {
buttonsContainer.style.display = 'block'; buttonsContainer.style.display = 'block';
} }
// Show skip button for manual download errors
const skipBtn = document.getElementById('update-skip-btn');
const footerText = document.getElementById('update-footer-text');
if (skipBtn) {
skipBtn.style.display = 'inline-block';
if (footerText) {
footerText.textContent = 'Or continue without updating:';
}
}
console.log('⚠️ Manual download required due to update error'); console.log('⚠️ Manual download required due to update error');
} }
@@ -300,13 +448,35 @@ class ClientUpdateManager {
document.body.classList.add('no-select'); document.body.classList.add('no-select');
document.addEventListener('keydown', this.blockKeyEvents.bind(this), true); // Store bound functions so we can remove them later
this._boundBlockKeyEvents = this.blockKeyEvents.bind(this);
this._boundBlockContextMenu = this.blockContextMenu.bind(this);
document.addEventListener('contextmenu', this.blockContextMenu.bind(this), true); document.addEventListener('keydown', this._boundBlockKeyEvents, true);
document.addEventListener('contextmenu', this._boundBlockContextMenu, true);
console.log('🚫 Interface blocked for update'); console.log('🚫 Interface blocked for update');
} }
unblockInterface() {
const mainContent = document.querySelector('.flex.w-full.h-screen');
if (mainContent) {
mainContent.classList.remove('interface-blocked');
}
document.body.classList.remove('no-select');
// Remove event listeners
if (this._boundBlockKeyEvents) {
document.removeEventListener('keydown', this._boundBlockKeyEvents, true);
}
if (this._boundBlockContextMenu) {
document.removeEventListener('contextmenu', this._boundBlockContextMenu, true);
}
console.log('✅ Interface unblocked');
}
blockKeyEvents(event) { blockKeyEvents(event) {
if (event.target.closest('#update-popup-overlay')) { if (event.target.closest('#update-popup-overlay')) {
if ((event.key === 'Enter' || event.key === ' ') && if ((event.key === 'Enter' || event.key === ' ') &&

View File

@@ -4,9 +4,10 @@
<h1>🎮 Hytale F2P Launcher 🚀</h1> <h1>🎮 Hytale F2P Launcher 🚀</h1>
<h2>💻 Cross-Platform Multiplayer 🖥️</h2> <h2>💻 Cross-Platform Multiplayer 🖥️</h2>
<h3>Available for Windows 🪟, macOS 🍎, and Linux 🐧</h3> <h3>Available for Windows 🪟, macOS 🍎, and Linux 🐧</h3>
<p><small>An unofficial cross-platform launcher for Hytale with automatic updates and multiplayer support (all OS supported)</small></p> <p><small>An unofficial cross-platform launcher for Hytale with automatic updates and multiplayer support!</small></p>
</header> </header>
![GitHub Downloads](https://img.shields.io/github/downloads/amiayweb/Hytale-F2P/total?style=for-the-badge)
![Version](https://img.shields.io/badge/Version-2.1.1-green?style=for-the-badge) ![Version](https://img.shields.io/badge/Version-2.1.1-green?style=for-the-badge)
![Platform](https://img.shields.io/badge/Platform-Windows%20%7C%20macOS%20%7C%20Linux-orange?style=for-the-badge) ![Platform](https://img.shields.io/badge/Platform-Windows%20%7C%20macOS%20%7C%20Linux-orange?style=for-the-badge)
![License](https://img.shields.io/badge/License-Educational-blue?style=for-the-badge) ![License](https://img.shields.io/badge/License-Educational-blue?style=for-the-badge)
@@ -118,9 +119,9 @@
<tr> <tr>
<td><b>🖥️ OS</b></td> <td><b>🖥️ OS</b></td>
<td colspan="3" align="center"> <td colspan="3" align="center">
Windows 10/11 (64-bit; X64/ARM64) | Linux (x64/ARM64) | macOS (Apple Silicon only) Windows 10/11 (64-bit X64) | Linux (x64) | macOS (ARM64/Apple Silicon)
<br /> <br />
<small><i>⚠️ Note: macOS Intel (x86) is not yet supported <sup><a href="#fn1" id="ref1">1</a></sup></i></small> <small><i>⚠️ Note: ARM64 (Windows & Linux), macOS (x86/Intel) <b>are not supported!</b> ⚠️</i></small>
</td> </td>
</tr> </tr>
<tr> <tr>
@@ -131,7 +132,7 @@
</tr> </tr>
<tr> <tr>
<td><b>🧠 RAM</b></td> <td><b>🧠 RAM</b></td>
<td>8GB (dGPU)<sup><a href="#fn1" id="ref2">2</a></sup> /<br>12GB (iGPU)<sup><a href="#fn1" id="ref3">3</a></sup></td> <td>8GB (dGPU) / 12GB (iGPU)<sup><a href="#fn1" id="ref1">1</a></sup></td>
<td>16 GB</td> <td>16 GB</td>
<td>32 GB</td> <td>32 GB</td>
</tr> </tr>
@@ -156,19 +157,19 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<p id="fn1"><sup>Note 1</sup> Hytale did not provide game files for macOS Intel, yet.</p> <p id="fn1"><sup>Note 1</sup> Using Discrete/Dedicated GPU (dGPU) must have 8 GB RAM minimum, while using Integrated GPU (iGPU) must have 12 GB RAM.</p>
<p id="fn2"><sup>Note 2</sup> Using Discrete/Dedicated GPU (dGPU) must have 8 GB RAM minimum.</p>
<p id="fn3"><sup>Note 3</sup> Using Integrated GPU (dGPU) must have 12 GB RAM minimum.</p>
> [!WARNING]
> Our launcher has **not yet** supported Offline Mode (playing Hytale without internet).
> We will surely add the feature as soon as possible. Kindly wait for the update.
---
### 🪟 Windows Prequisites ### 🪟 Windows Prequisites
* **
* **Java JDK 25:** * **Java JDK 25:**
* [Oracle](https://www.oracle.com/java/technologies/downloads/#jdk25-windows), **no** support for Windows ARM64 in both version 25 and 21. * [Oracle](https://www.oracle.com/java/technologies/downloads/#jdk25-windows)
* [Adoptium](https://adoptium.net/temurin/releases/?version=25), has Windows ARM64 support in version 21 only. * [Adoptium](https://adoptium.net/temurin/releases/?version=25)
* [Microsoft](https://learn.microsoft.com/en-us/java/openjdk/download), has Windows ARM64 support in version 25. * [Microsoft](https://learn.microsoft.com/en-us/java/openjdk/download), has Windows ARM64 support in version 25.
* Download from any vendor if your OS is not Windows with ARM64 architecture.
* **Latest Visual Studio Redist:** * **Latest Visual Studio Redist:**
* Download via [Microsoft Visual C++ Redistributable](https://aka.ms/vc14/vc_redist.x64.exe) * Download via [Microsoft Visual C++ Redistributable](https://aka.ms/vc14/vc_redist.x64.exe)
* Or [All-in-One by Techpowerup](https://www.techpowerup.com/download/visual-c-redistributable-runtime-package-all-in-one/) * Or [All-in-One by Techpowerup](https://www.techpowerup.com/download/visual-c-redistributable-runtime-package-all-in-one/)
@@ -178,9 +179,9 @@
> [!WARNING] > [!WARNING]
> Ubuntu-based Distro like ZorinOS or Pop!_OS or Linux Mint would encounter issues due to UbuntuLTS environment, [check this Discord post](https://discord.com/channels/1462260103951421493/1463662398501027973). > Ubuntu-based Distro like ZorinOS or Pop!_OS or Linux Mint would encounter issues due to UbuntuLTS environment, [check this Discord post](https://discord.com/channels/1462260103951421493/1463662398501027973).
* Make sure you have already installed newest **GPU driver**, consult your distro docs or wiki. * Make sure you have already installed newest **GPU driver** especially proprietary NVIDIA, consult your distro docs or wiki.
* Also make sure that your GPU can be connected to EGL, try checking it first (again, consult your distro docs or wiki) before installing Hytale game via our launcher.
* Install `libpng` package to avoid SDL3_Image error: * Install `libpng` package to avoid `SDL3_Image` error:
* `libpng16-16 libpng-dev` for Ubuntu/Debian-based Distro * `libpng16-16 libpng-dev` for Ubuntu/Debian-based Distro
* `libpng libpng-devel` for Fedora/RHEL-based Distro * `libpng libpng-devel` for Fedora/RHEL-based Distro
* `libpng` for Arch-based Distro * `libpng` for Arch-based Distro
@@ -194,11 +195,12 @@
1. **Prerequisites:** Ensure you have installed all [**Windows Prerequisites**](https://github.com/amiayweb/Hytale-F2P/tree/main?tab=readme-ov-file#-windows-prequisites) listed above. 1. **Prerequisites:** Ensure you have installed all [**Windows Prerequisites**](https://github.com/amiayweb/Hytale-F2P/tree/main?tab=readme-ov-file#-windows-prequisites) listed above.
2. **Download:** Get the latest `Hytale-F2P-Launcher.exe` from the [**Releases**](https://github.com/amiayweb/Hytale-F2P/releases/latest/) page. 2. **Download:** Get the latest `Hytale-F2P-Launcher.exe` from the [**Releases**](https://github.com/amiayweb/Hytale-F2P/releases/latest/) page.
3. **SmartScreen Note:** Since the executable is currently unsigned, Windows may show a "Windows protected your PC" popup. 3. **SmartScreen Note:** Since the executable is currently unsigned, Windows may show a "Windows protected your PC" popup.
* Click **More info**. * Click **More info**, then click **Run anyway**.
* Click **Run anyway**.
4. **Launch:** Once installed, you can launch the app directly from your Desktop or the Start menu. 4. **Launch:** Once installed, you can launch the app directly from your Desktop or the Start menu.
5. **Whitelist in Windows Firewall** [#192](https://github.com/amiayweb/Hytale-F2P/issues/192#issuecomment-3803042908)
--- * Open the Windows Start Menu and search for `Allow an app through Windows Firewall`
* Click "Change settings" (you may need Admin privileges) and Locate `HytaleClient.exe` in the list.
* Ensure both the Private and Public checkboxes are checked. Click OK to save.
### 🐧 Linux Installation ### 🐧 Linux Installation
@@ -243,8 +245,6 @@
* **Desktop Entry:** After installing via `.rpm`, `.deb`, or `.pkg.tar.zst`, the launcher should automatically appear in your App Library/Grid. * **Desktop Entry:** After installing via `.rpm`, `.deb`, or `.pkg.tar.zst`, the launcher should automatically appear in your App Library/Grid.
* Missing libxcrypt.so.1: Install `libxcrypt-compat` using your package manager * Missing libxcrypt.so.1: Install `libxcrypt-compat` using your package manager
---
### 🍎 macOS Installation ### 🍎 macOS Installation
> [!NOTE] > [!NOTE]
@@ -272,9 +272,9 @@ The `.zip` version is useful for users who prefer a portable installation or nee
--- ---
# How to Host a Server # 📢 How to Host a Server
## Host your Singleplayer Server (Online-Play Feature) ## 🌐 Host your Singleplayer Server (Online-Play Feature)
> [!NOTE] > [!NOTE]
> You have to play the game to host the server. See Dedicated Server section below if you want to host it without you playing as the host. > You have to play the game to host the server. See Dedicated Server section below if you want to host it without you playing as the host.
@@ -283,23 +283,29 @@ The `.zip` version is useful for users who prefer a portable installation or nee
2. Pause the game (Esc) > select Online Play > Turn on `Allow Other Players to Join` > Set password if needed > Press `Save`. 2. Pause the game (Esc) > select Online Play > Turn on `Allow Other Players to Join` > Set password if needed > Press `Save`.
3. Check the status `Connected via STUN` or `Connected via UPnP`. 3. Check the status `Connected via STUN` or `Connected via UPnP`.
## Dedicated Server ## 🖧 Host a Dedicated Server
> [!NOTE] > [!NOTE]
> If you have already `HytaleServer.jar` in `HytaleF2P/{release/pre-release}/package/game/latest/Server`, you can use it to host local dedicated server. > If you already have the patched `HytaleServer.jar` in `HytaleF2P/{release/pre-release}/package/game/latest/Server`, you can use it to host local dedicated server.
> [!TIP] > [!TIP]
> Use services like Playit.gg, Tailscale, Radmin VPN to share UDP connection if setting up router as an admin is not possible. > Use services like Playit.gg, Tailscale, Radmin VPN to share UDP connection if setting up router as an admin is not possible.
> [!WARNING] > [!WARNING]
> `Hytale-F2P-Server.rar` file is needed to set up a server on non-playing hardware (such as VPS/server hosting). > `Hytale-F2P-Server.rar` file is needed to set up a server on non-playing hardware (such as VPS/server hosting). Linux ARM64 is supported for server only.
> [!IMPORTANT] > [!IMPORTANT]
> See detailed information of setting up a server here: [SERVER.md](SERVER.md) > See detailed information of setting up a server here: [SERVER.md](SERVER.md). Download the latest patched JAR, the patched RAR, or the SH/BAT scripts from channel `#open-public-server` in our Discord Server.
--- ---
## 🛠️ Building from Source ## 🔧 Troubleshooting
See [TROUBLESHOOTING.md](TROUBLESHOOTING.md) for detailed Troubleshooting guide.
---
## 🔨 Building from Source
See [BUILD.md](docs/BUILD.md) for comprehensive build instructions. See [BUILD.md](docs/BUILD.md) for comprehensive build instructions.

View File

@@ -24,57 +24,6 @@ try {
const execAsync = promisify(exec); 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) // Fetch tokens from the auth server (properly signed with server's Ed25519 key)
async function fetchAuthTokens(uuid, name) { async function fetchAuthTokens(uuid, name) {
const authServerUrl = getAuthServerUrl(); const authServerUrl = getAuthServerUrl();
@@ -336,62 +285,53 @@ exec "$REAL_JAVA" "\${ARGS[@]}"
const gpuEnv = setupGpuEnvironment(gpuPreference); const gpuEnv = setupGpuEnvironment(gpuPreference);
Object.assign(env, gpuEnv); Object.assign(env, gpuEnv);
// Linux: Use jemalloc to fix "free(): invalid pointer" crash on glibc 2.41+ (Steam Deck, Ubuntu LTS) // Linux: Replace bundled libzstd.so with system version to fix glibc 2.41+ crash
// Root cause: glibc 2.41 has stricter heap validation that catches a pre-existing race condition // The bundled libzstd causes "free(): invalid pointer" on Steam Deck / Ubuntu LTS
if (process.platform === 'linux') { if (process.platform === 'linux' && process.env.HYTALE_NO_LIBZSTD_FIX !== '1') {
if (process.env.HYTALE_NO_JEMALLOC !== '1') { const clientDir = path.dirname(clientPath);
const jemallocPaths = [ const bundledLibzstd = path.join(clientDir, 'libzstd.so');
'/usr/lib/libjemalloc.so.2', // Arch Linux, Steam Deck const backupLibzstd = path.join(clientDir, 'libzstd.so.bundled');
'/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; // Common system libzstd paths
for (const p of jemallocPaths) { const systemLibzstdPaths = [
if (fs.existsSync(p)) { '/usr/lib/libzstd.so.1', // Arch Linux, Steam Deck
jemalloc = p; '/usr/lib/x86_64-linux-gnu/libzstd.so.1', // Debian/Ubuntu
break; '/usr/lib64/libzstd.so.1' // Fedora/RHEL
} ];
}
if (jemalloc) { let systemLibzstd = null;
env.LD_PRELOAD = jemalloc + (env.LD_PRELOAD ? ':' + env.LD_PRELOAD : ''); for (const p of systemLibzstdPaths) {
console.log(`Linux: Using jemalloc allocator for stability (${jemalloc})`); if (fs.existsSync(p)) {
} else { systemLibzstd = p;
// Try auto-install break;
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) { if (systemLibzstd && fs.existsSync(bundledLibzstd)) {
env.MALLOC_CHECK_ = '0'; try {
console.log('Linux: jemalloc not found - install with: sudo pacman -S jemalloc (Arch) or sudo apt install libjemalloc2 (Debian/Ubuntu)'); const stats = fs.lstatSync(bundledLibzstd);
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');
}
}
// Debug: log LD_PRELOAD before spawn // Only replace if it's not already a symlink to system version
if (process.platform === 'linux') { if (!stats.isSymbolicLink()) {
console.log(`Linux: LD_PRELOAD = ${env.LD_PRELOAD || '(not set)'}`); // Backup bundled version
if (!fs.existsSync(backupLibzstd)) {
fs.renameSync(bundledLibzstd, backupLibzstd);
console.log(`Linux: Backed up bundled libzstd.so`);
} else {
fs.unlinkSync(bundledLibzstd);
}
// Create symlink to system version
fs.symlinkSync(systemLibzstd, bundledLibzstd);
console.log(`Linux: Linked libzstd.so to system version (${systemLibzstd}) for glibc 2.41+ compatibility`);
} else {
const linkTarget = fs.readlinkSync(bundledLibzstd);
console.log(`Linux: libzstd.so already linked to ${linkTarget}`);
}
} catch (libzstdError) {
console.warn(`Linux: Could not replace libzstd.so: ${libzstdError.message}`);
}
}
} }
try { try {
@@ -406,19 +346,7 @@ exec "$REAL_JAVA" "\${ARGS[@]}"
spawnOptions.windowsHide = true; spawnOptions.windowsHide = true;
} }
let child; const child = spawn(clientPath, args, spawnOptions);
// Linux: Use shell with inline LD_PRELOAD for maximum compatibility
if (process.platform === 'linux' && env.LD_PRELOAD) {
const quotedArgs = args.map(a => `"${a.replace(/"/g, '\\"')}"`).join(' ');
const shellCmd = `LD_PRELOAD="${env.LD_PRELOAD}" "${clientPath}" ${quotedArgs}`;
console.log(`Linux: Launching via shell with LD_PRELOAD`);
spawnOptions.shell = '/bin/bash';
child = spawn(shellCmd, [], spawnOptions);
} else {
child = spawn(clientPath, args, spawnOptions);
}
console.log(`Game process started with PID: ${child.pid}`); console.log(`Game process started with PID: ${child.pid}`);

View File

@@ -1,10 +1,5 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const crypto = require('crypto');
const AdmZip = require('adm-zip');
const { execSync, spawn } = require('child_process');
const { getJavaExec, getBundledJavaPath } = require('../managers/javaManager');
const { JRE_DIR } = require('../core/paths');
// Domain configuration // Domain configuration
const ORIGINAL_DOMAIN = 'hytale.com'; const ORIGINAL_DOMAIN = 'hytale.com';
@@ -26,15 +21,13 @@ function getTargetDomain() {
const DEFAULT_NEW_DOMAIN = 'auth.sanasol.ws'; const DEFAULT_NEW_DOMAIN = 'auth.sanasol.ws';
/** /**
* Patches HytaleClient and HytaleServer binaries to replace hytale.com with custom domain * Patches HytaleClient binary to replace hytale.com with custom domain
* This allows the game to connect to a custom authentication server * Server patching is done via pre-patched JAR download from CDN
* *
* Supports domains from 4 to 16 characters: * Supports domains from 4 to 16 characters:
* - All F2P traffic routes to single endpoint: https://{domain} (no subdomains) * - All F2P traffic routes to single endpoint: https://{domain} (no subdomains)
* - Domains <= 10 chars: Direct replacement, subdomains stripped * - Domains <= 10 chars: Direct replacement, subdomains stripped
* - Domains 11-16 chars: Split mode - first 6 chars replace subdomain prefix, rest replaces domain * - Domains 11-16 chars: Split mode - first 6 chars replace subdomain prefix, rest replaces domain
*
* Official hytale.com keeps original subdomain behavior (sessions., account-data., etc.)
*/ */
class ClientPatcher { class ClientPatcher {
constructor() { constructor() {
@@ -61,7 +54,6 @@ class ClientPatcher {
/** /**
* Calculate the domain patching strategy based on length * Calculate the domain patching strategy based on length
* @returns {object} Strategy with mainDomain and subdomainPrefix
*/ */
getDomainStrategy(domain) { getDomainStrategy(domain) {
if (domain.length <= 10) { if (domain.length <= 10) {
@@ -85,12 +77,10 @@ class ClientPatcher {
/** /**
* Convert a string to the length-prefixed byte format used by the client * 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]
*/ */
stringToLengthPrefixed(str) { stringToLengthPrefixed(str) {
const length = str.length; const length = str.length;
const result = Buffer.alloc(4 + length + (length - 1)); const result = Buffer.alloc(4 + length + (length - 1));
result[0] = length; result[0] = length;
result[1] = 0x00; result[1] = 0x00;
result[2] = 0x00; result[2] = 0x00;
@@ -103,7 +93,6 @@ class ClientPatcher {
result[pos++] = 0x00; result[pos++] = 0x00;
} }
} }
return result; return result;
} }
@@ -118,13 +107,6 @@ class ClientPatcher {
return buf; return buf;
} }
/**
* Convert a string to UTF-8 bytes (how Java stores strings)
*/
stringToUtf8(str) {
return Buffer.from(str, 'utf8');
}
/** /**
* Find all occurrences of a pattern in a buffer * Find all occurrences of a pattern in a buffer
*/ */
@@ -142,7 +124,6 @@ class ClientPatcher {
/** /**
* Replace bytes in buffer - only overwrites the length of new bytes * Replace bytes in buffer - only overwrites the length of new bytes
* Does NOT null-pad to avoid corrupting adjacent data
*/ */
replaceBytes(buffer, oldBytes, newBytes) { replaceBytes(buffer, oldBytes, newBytes) {
let count = 0; let count = 0;
@@ -154,9 +135,7 @@ class ClientPatcher {
} }
const positions = this.findAllOccurrences(result, oldBytes); const positions = this.findAllOccurrences(result, oldBytes);
for (const pos of positions) { for (const pos of positions) {
// Only overwrite the length of the new bytes - don't null-fill!
newBytes.copy(result, pos); newBytes.copy(result, pos);
count++; count++;
} }
@@ -165,37 +144,12 @@ class ClientPatcher {
} }
/** /**
* UTF-8 domain replacement for Java JAR files * Smart domain replacement that handles both null-terminated and non-null-terminated strings
*/
findAndReplaceDomainUtf8(data, oldDomain, newDomain) {
let count = 0;
const result = Buffer.from(data);
const oldUtf8 = this.stringToUtf8(oldDomain);
const newUtf8 = this.stringToUtf8(newDomain);
const positions = this.findAllOccurrences(result, oldUtf8);
for (const pos of positions) {
newUtf8.copy(result, pos);
count++;
}
return { buffer: result, count };
}
/**
* Smart domain replacement for .NET AOT binaries
*/ */
findAndReplaceDomainSmart(data, oldDomain, newDomain) { findAndReplaceDomainSmart(data, oldDomain, newDomain) {
let count = 0; let count = 0;
const result = Buffer.from(data); 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 oldUtf16NoLast = this.stringToUtf16LE(oldDomain.slice(0, -1));
const newUtf16NoLast = this.stringToUtf16LE(newDomain.slice(0, -1)); const newUtf16NoLast = this.stringToUtf16LE(newDomain.slice(0, -1));
@@ -211,9 +165,17 @@ class ClientPatcher {
const lastCharFirstByte = result[lastCharPos]; const lastCharFirstByte = result[lastCharPos];
if (lastCharFirstByte === oldLastCharByte) { if (lastCharFirstByte === oldLastCharByte) {
// Only overwrite, don't null-fill
newUtf16NoLast.copy(result, pos); newUtf16NoLast.copy(result, pos);
result[lastCharPos] = newLastCharByte; 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)})`);
}
}
count++; count++;
} }
} }
@@ -231,10 +193,24 @@ class ClientPatcher {
console.log(` Patching strategy: ${strategy.description}`); console.log(` Patching strategy: ${strategy.description}`);
// ULTRA-MINIMAL PATCHING - only domain, no subdomain patches // 1. Patch telemetry/sentry URL
console.log(` Ultra-minimal mode: only patching main domain`); const oldSentry = 'https://ca900df42fcf57d4dd8401a86ddd7da2@sentry.hytale.com/2';
const newSentry = `${protocol}t@${domain}/2`;
// Only patch main domain (hytale.com -> mainDomain) 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
console.log(` Patching domain: ${ORIGINAL_DOMAIN} -> ${strategy.mainDomain}`);
const domainResult = this.replaceBytes( const domainResult = this.replaceBytes(
result, result,
this.stringToLengthPrefixed(ORIGINAL_DOMAIN), this.stringToLengthPrefixed(ORIGINAL_DOMAIN),
@@ -242,48 +218,125 @@ class ClientPatcher {
); );
result = domainResult.buffer; result = domainResult.buffer;
if (domainResult.count > 0) { if (domainResult.count > 0) {
console.log(` Patched ${domainResult.count} domain occurrence(s)`); console.log(` Replaced ${domainResult.count} domain occurrence(s)`);
totalCount += domainResult.count; totalCount += domainResult.count;
} }
// Skip ALL subdomain patches - let them stay as sessions.hytale.com etc // 3. Patch subdomain prefixes
console.log(` Skipping all subdomain patches (ultra-minimal mode)`); 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;
}
}
return { buffer: result, count: totalCount }; return { buffer: result, count: totalCount };
} }
/** /**
* Check if the client binary has already been patched * Patch Discord invite URLs
*/ */
isPatchedAlready(clientPath) { patchDiscordUrl(data) {
let count = 0;
const result = Buffer.from(data);
const oldUrl = '.gg/hytale';
const newUrl = '.gg/MHkEjepMQ7';
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 patch status of client binary
*/
getPatchStatus(clientPath) {
const newDomain = this.getNewDomain(); const newDomain = this.getNewDomain();
const patchFlagFile = clientPath + this.patchedFlag; const patchFlagFile = clientPath + this.patchedFlag;
if (fs.existsSync(patchFlagFile)) { if (fs.existsSync(patchFlagFile)) {
try { try {
const flagData = JSON.parse(fs.readFileSync(patchFlagFile, 'utf8')); const flagData = JSON.parse(fs.readFileSync(patchFlagFile, 'utf8'));
if (flagData.targetDomain === newDomain) { const currentDomain = flagData.targetDomain;
// Verify the binary actually contains the patched domain
if (currentDomain === newDomain) {
const data = fs.readFileSync(clientPath); const data = fs.readFileSync(clientPath);
const strategy = this.getDomainStrategy(newDomain); const strategy = this.getDomainStrategy(newDomain);
const domainPattern = this.stringToLengthPrefixed(strategy.mainDomain); const domainPattern = this.stringToLengthPrefixed(strategy.mainDomain);
if (data.includes(domainPattern)) { if (data.includes(domainPattern)) {
return true; return { patched: true, currentDomain, needsRestore: false };
} else { } else {
console.log(' Flag exists but binary not patched (was updated?), re-patching...'); console.log(' Flag exists but binary not patched (was updated?), needs re-patching...');
return false; return { patched: false, currentDomain: null, needsRestore: false };
} }
} else {
console.log(` Currently patched for "${currentDomain}", need to change to "${newDomain}"`);
return { patched: false, currentDomain, needsRestore: true };
} }
} catch (e) { } catch (e) {
// Flag file corrupt // Flag file corrupt
} }
} }
return { patched: false, currentDomain: null, needsRestore: false };
}
/**
* Check if client is already patched (backward compat)
*/
isPatchedAlready(clientPath) {
return this.getPatchStatus(clientPath).patched;
}
/**
* Restore client from backup
*/
restoreFromBackup(clientPath) {
const backupPath = clientPath + '.original';
if (fs.existsSync(backupPath)) {
console.log(' Restoring original binary from backup for re-patching...');
fs.copyFileSync(backupPath, clientPath);
const patchFlagFile = clientPath + this.patchedFlag;
if (fs.existsSync(patchFlagFile)) {
fs.unlinkSync(patchFlagFile);
}
return true;
}
console.warn(' No backup found to restore - will try patching anyway');
return false; return false;
} }
/** /**
* Mark the client as patched * Mark client as patched
*/ */
markAsPatched(clientPath) { markAsPatched(clientPath) {
const newDomain = this.getNewDomain(); const newDomain = this.getNewDomain();
@@ -296,40 +349,46 @@ class ClientPatcher {
patchMode: strategy.mode, patchMode: strategy.mode,
mainDomain: strategy.mainDomain, mainDomain: strategy.mainDomain,
subdomainPrefix: strategy.subdomainPrefix, subdomainPrefix: strategy.subdomainPrefix,
patcherVersion: '2.1.0' patcherVersion: '2.1.0',
verified: 'binary_contents'
}; };
fs.writeFileSync(patchFlagFile, JSON.stringify(flagData, null, 2)); fs.writeFileSync(patchFlagFile, JSON.stringify(flagData, null, 2));
} }
/** /**
* Create a backup of the original client binary * Create backup of original client binary
*/ */
backupClient(clientPath) { backupClient(clientPath) {
const backupPath = clientPath + '.original'; const backupPath = clientPath + '.original';
if (!fs.existsSync(backupPath)) { try {
console.log(` Creating backup at ${path.basename(backupPath)}`); if (!fs.existsSync(backupPath)) {
fs.copyFileSync(clientPath, backupPath); console.log(` Creating backup at ${path.basename(backupPath)}`);
fs.copyFileSync(clientPath, backupPath);
return backupPath;
}
const currentSize = fs.statSync(clientPath).size;
const backupSize = fs.statSync(backupPath).size;
if (currentSize !== backupSize) {
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)}`);
fs.renameSync(backupPath, oldBackupPath);
fs.copyFileSync(clientPath, backupPath);
return backupPath;
}
console.log(' Backup already exists');
return backupPath; return backupPath;
} catch (e) {
console.error(` Failed to create backup: ${e.message}`);
return null;
} }
const currentSize = fs.statSync(clientPath).size;
const backupSize = fs.statSync(backupPath).size;
if (currentSize !== backupSize) {
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)}`);
fs.renameSync(backupPath, oldBackupPath);
fs.copyFileSync(clientPath, backupPath);
return backupPath;
}
console.log(' Backup already exists');
return backupPath;
} }
/** /**
* Restore the original client binary from backup * Restore original client binary
*/ */
restoreClient(clientPath) { restoreClient(clientPath) {
const backupPath = clientPath + '.original'; const backupPath = clientPath + '.original';
@@ -357,6 +416,10 @@ class ClientPatcher {
console.log(`Target: ${clientPath}`); console.log(`Target: ${clientPath}`);
console.log(`Domain: ${newDomain} (${newDomain.length} chars)`); console.log(`Domain: ${newDomain} (${newDomain.length} chars)`);
console.log(`Mode: ${strategy.mode}`); 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)) { if (!fs.existsSync(clientPath)) {
const error = `Client binary not found: ${clientPath}`; const error = `Client binary not found: ${clientPath}`;
@@ -364,16 +427,26 @@ class ClientPatcher {
return { success: false, error }; return { success: false, error };
} }
if (this.isPatchedAlready(clientPath)) { const patchStatus = this.getPatchStatus(clientPath);
if (patchStatus.patched) {
console.log(`Client already patched for ${newDomain}, skipping`); 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 }; return { success: true, alreadyPatched: true, patchCount: 0 };
} }
if (patchStatus.needsRestore) {
if (progressCallback) progressCallback('Restoring original for domain change...', 5);
this.restoreFromBackup(clientPath);
}
if (progressCallback) progressCallback('Preparing to patch client...', 10); if (progressCallback) progressCallback('Preparing to patch client...', 10);
console.log('Creating backup...'); console.log('Creating backup...');
this.backupClient(clientPath); const backupResult = this.backupClient(clientPath);
if (!backupResult) {
console.warn(' Could not create backup - proceeding without backup');
}
if (progressCallback) progressCallback('Reading client binary...', 20); if (progressCallback) progressCallback('Reading client binary...', 20);
@@ -383,12 +456,15 @@ class ClientPatcher {
if (progressCallback) progressCallback('Patching domain references...', 50); if (progressCallback) progressCallback('Patching domain references...', 50);
console.log('Applying domain patches...'); console.log('Applying domain patches (length-prefixed format)...');
const { buffer: patchedData, count } = this.applyDomainPatches(data, newDomain); const { buffer: patchedData, count } = this.applyDomainPatches(data, newDomain);
if (count === 0) { console.log('Patching Discord URLs...');
// Try legacy UTF-16LE format const { buffer: finalData, count: discordCount } = this.patchDiscordUrl(patchedData);
if (count === 0 && discordCount === 0) {
console.log('No occurrences found - trying legacy UTF-16LE format...'); console.log('No occurrences found - trying legacy UTF-16LE format...');
const legacyResult = this.findAndReplaceDomainSmart(data, ORIGINAL_DOMAIN, strategy.mainDomain); const legacyResult = this.findAndReplaceDomainSmart(data, ORIGINAL_DOMAIN, strategy.mainDomain);
if (legacyResult.count > 0) { if (legacyResult.count > 0) {
console.log(`Found ${legacyResult.count} occurrences with legacy format`); console.log(`Found ${legacyResult.count} occurrences with legacy format`);
@@ -397,31 +473,65 @@ class ClientPatcher {
return { success: true, patchCount: legacyResult.count, format: 'legacy' }; return { success: true, patchCount: legacyResult.count, format: 'legacy' };
} }
console.log('No occurrences found - binary may already be modified'); console.log('No occurrences found - binary may already be modified or has different format');
return { success: true, patchCount: 0, warning: 'No occurrences found' }; 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...'); console.log('Writing patched binary...');
fs.writeFileSync(clientPath, patchedData); fs.writeFileSync(clientPath, finalData);
this.markAsPatched(clientPath); this.markAsPatched(clientPath);
if (progressCallback) progressCallback('Patching complete', 100); if (progressCallback) progressCallback('Patching complete', 100);
console.log(`Successfully patched ${count} occurrences`); console.log(`Successfully patched ${count} domain occurrences and ${discordCount} Discord URLs`);
console.log('=== Patching Complete ==='); console.log('=== Patching Complete ===');
return { success: true, patchCount: count }; return { success: true, patchCount: count + discordCount };
} }
/** /**
* Patch the server JAR by downloading pre-patched version * Check if server JAR contains DualAuth classes (was patched)
*/ */
async patchServer(serverPath, progressCallback, javaPath = null) { serverJarContainsDualAuth(serverPath) {
try {
const data = fs.readFileSync(serverPath);
// Check for DualAuthContext class signature in JAR
const signature = Buffer.from('DualAuthContext', 'utf8');
return data.includes(signature);
} catch (e) {
return false;
}
}
/**
* Validate downloaded file is not corrupt/partial
* Server JAR should be at least 50MB
*/
validateServerJarSize(serverPath) {
try {
const stats = fs.statSync(serverPath);
const minSize = 50 * 1024 * 1024; // 50MB minimum
if (stats.size < minSize) {
console.error(` Downloaded JAR too small: ${(stats.size / 1024 / 1024).toFixed(2)} MB (expected >50MB)`);
return false;
}
console.log(` Downloaded size: ${(stats.size / 1024 / 1024).toFixed(2)} MB`);
return true;
} catch (e) {
return false;
}
}
/**
* Patch server JAR by downloading pre-patched version from CDN
*/
async patchServer(serverPath, progressCallback) {
const newDomain = this.getNewDomain(); const newDomain = this.getNewDomain();
console.log('=== Server Patcher ==='); console.log('=== Server Patcher (Pre-patched Download) ===');
console.log(`Target: ${serverPath}`); console.log(`Target: ${serverPath}`);
console.log(`Domain: ${newDomain}`); console.log(`Domain: ${newDomain}`);
@@ -431,26 +541,67 @@ class ClientPatcher {
return { success: false, error }; return { success: false, error };
} }
// Check if already patched
const patchFlagFile = serverPath + '.dualauth_patched'; const patchFlagFile = serverPath + '.dualauth_patched';
let needsRestore = false;
if (fs.existsSync(patchFlagFile)) { if (fs.existsSync(patchFlagFile)) {
try { try {
const flagData = JSON.parse(fs.readFileSync(patchFlagFile, 'utf8')); const flagData = JSON.parse(fs.readFileSync(patchFlagFile, 'utf8'));
if (flagData.domain === newDomain) { if (flagData.domain === newDomain) {
console.log(`Server already patched for ${newDomain}, skipping`); // Verify JAR actually contains DualAuth classes (game may have auto-updated)
if (progressCallback) progressCallback('Server already patched', 100); if (this.serverJarContainsDualAuth(serverPath)) {
return { success: true, alreadyPatched: true }; console.log(`Server already patched for ${newDomain}, skipping`);
if (progressCallback) progressCallback('Server already patched', 100);
return { success: true, alreadyPatched: true };
} else {
console.log(' Flag exists but JAR not patched (was auto-updated?), will re-download...');
// Delete stale flag file
try { fs.unlinkSync(patchFlagFile); } catch (e) { /* ignore */ }
}
} else {
console.log(`Server patched for "${flagData.domain}", need to change to "${newDomain}"`);
needsRestore = true;
} }
} catch (e) { } catch (e) {
// Re-patch // Flag file corrupt, re-patch
console.log(' Flag file corrupt, will re-download');
try { fs.unlinkSync(patchFlagFile); } catch (e) { /* ignore */ }
} }
} }
// Restore backup if patched for different domain
if (needsRestore) {
const backupPath = serverPath + '.original';
if (fs.existsSync(backupPath)) {
if (progressCallback) progressCallback('Restoring original for domain change...', 5);
console.log('Restoring original JAR from backup for re-patching...');
fs.copyFileSync(backupPath, serverPath);
if (fs.existsSync(patchFlagFile)) {
fs.unlinkSync(patchFlagFile);
}
} else {
console.warn(' No backup found to restore - will download fresh patched JAR');
}
}
// Create backup
if (progressCallback) progressCallback('Creating backup...', 10); if (progressCallback) progressCallback('Creating backup...', 10);
console.log('Creating backup...'); console.log('Creating backup...');
this.backupClient(serverPath); const backupResult = this.backupClient(serverPath);
if (!backupResult) {
console.warn(' Could not create backup - proceeding without backup');
}
// Only support standard domain (auth.sanasol.ws) via pre-patched download
if (newDomain !== 'auth.sanasol.ws' && newDomain !== 'sanasol.ws') {
console.error(`Domain "${newDomain}" requires DualAuthPatcher - only auth.sanasol.ws is supported via pre-patched download`);
return { success: false, error: `Unsupported domain: ${newDomain}. Only auth.sanasol.ws is supported.` };
}
// Download pre-patched JAR
if (progressCallback) progressCallback('Downloading patched server JAR...', 30); if (progressCallback) progressCallback('Downloading patched server JAR...', 30);
console.log('Downloading pre-patched HytaleServer.jar'); console.log('Downloading pre-patched HytaleServer.jar...');
try { try {
const https = require('https'); const https = require('https');
@@ -464,7 +615,7 @@ class ClientPatcher {
} }
if (response.statusCode !== 200) { if (response.statusCode !== 200) {
reject(new Error(`HTTP ${response.statusCode}`)); reject(new Error(`Failed to download: HTTP ${response.statusCode}`));
return; return;
} }
@@ -495,11 +646,42 @@ class ClientPatcher {
console.log(' Download successful'); console.log(' Download successful');
// Verify downloaded JAR size and contents
if (progressCallback) progressCallback('Verifying downloaded JAR...', 95);
if (!this.validateServerJarSize(serverPath)) {
console.error('Downloaded JAR appears corrupt or incomplete');
// Restore backup on verification failure
const backupPath = serverPath + '.original';
if (fs.existsSync(backupPath)) {
fs.copyFileSync(backupPath, serverPath);
console.log('Restored backup after verification failure');
}
return { success: false, error: 'Downloaded JAR verification failed - file too small (corrupt/partial download)' };
}
if (!this.serverJarContainsDualAuth(serverPath)) {
console.error('Downloaded JAR does not contain DualAuth classes - invalid or corrupt download');
// Restore backup on verification failure
const backupPath = serverPath + '.original';
if (fs.existsSync(backupPath)) {
fs.copyFileSync(backupPath, serverPath);
console.log('Restored backup after verification failure');
}
return { success: false, error: 'Downloaded JAR verification failed - missing DualAuth classes' };
}
console.log(' Verification successful - DualAuth classes present');
// Mark as patched
fs.writeFileSync(patchFlagFile, JSON.stringify({ fs.writeFileSync(patchFlagFile, JSON.stringify({
domain: newDomain, domain: newDomain,
patchedAt: new Date().toISOString(), patchedAt: new Date().toISOString(),
patcher: 'PrePatchedDownload', patcher: 'PrePatchedDownload',
source: url source: 'https://download.sanasol.ws/download/HytaleServer.jar'
})); }));
if (progressCallback) progressCallback('Server patching complete', 100); if (progressCallback) progressCallback('Server patching complete', 100);
@@ -509,6 +691,7 @@ class ClientPatcher {
} catch (downloadError) { } catch (downloadError) {
console.error(`Failed to download patched JAR: ${downloadError.message}`); console.error(`Failed to download patched JAR: ${downloadError.message}`);
// Restore backup on failure
const backupPath = serverPath + '.original'; const backupPath = serverPath + '.original';
if (fs.existsSync(backupPath)) { if (fs.existsSync(backupPath)) {
fs.copyFileSync(backupPath, serverPath); fs.copyFileSync(backupPath, serverPath);
@@ -520,40 +703,7 @@ class ClientPatcher {
} }
/** /**
* Find Java executable * Find client binary path based on platform
*/
findJava() {
try {
const bundled = getBundledJavaPath(JRE_DIR);
if (bundled && fs.existsSync(bundled)) {
return bundled;
}
} catch (e) {}
try {
const javaExec = getJavaExec(JRE_DIR);
if (javaExec && fs.existsSync(javaExec)) {
return javaExec;
}
} catch (e) {}
if (process.env.JAVA_HOME) {
const javaBin = path.join(process.env.JAVA_HOME, 'bin', process.platform === 'win32' ? 'java.exe' : 'java');
if (fs.existsSync(javaBin)) {
return javaBin;
}
}
try {
execSync('java -version 2>&1', { encoding: 'utf8' });
return 'java';
} catch (e) {}
return null;
}
/**
* Find the client binary path based on platform
*/ */
findClientPath(gameDir) { findClientPath(gameDir) {
const candidates = []; const candidates = [];
@@ -575,6 +725,9 @@ class ClientPatcher {
return null; return null;
} }
/**
* Find server JAR path
*/
findServerPath(gameDir) { findServerPath(gameDir) {
const candidates = [ const candidates = [
path.join(gameDir, 'Server', 'HytaleServer.jar'), path.join(gameDir, 'Server', 'HytaleServer.jar'),
@@ -603,7 +756,9 @@ class ClientPatcher {
if (clientPath) { if (clientPath) {
if (progressCallback) progressCallback('Patching client binary...', 10); if (progressCallback) progressCallback('Patching client binary...', 10);
results.client = await this.patchClient(clientPath, (msg, pct) => { 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 { } else {
console.warn('Could not find HytaleClient binary'); console.warn('Could not find HytaleClient binary');
@@ -614,8 +769,10 @@ class ClientPatcher {
if (serverPath) { if (serverPath) {
if (progressCallback) progressCallback('Patching server JAR...', 50); if (progressCallback) progressCallback('Patching server JAR...', 50);
results.server = await this.patchServer(serverPath, (msg, pct) => { results.server = await this.patchServer(serverPath, (msg, pct) => {
if (progressCallback) progressCallback(`Server: ${msg}`, pct ? 50 + pct / 2 : null); if (progressCallback) {
}, javaPath); progressCallback(`Server: ${msg}`, pct ? 50 + pct / 2 : null);
}
});
} else { } else {
console.warn('Could not find HytaleServer.jar'); console.warn('Could not find HytaleServer.jar');
results.server = { success: false, error: 'Server JAR not found' }; results.server = { success: false, error: 'Server JAR not found' };

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
</dict>
</plist>

View File

@@ -1,10 +1,10 @@
# Steam Deck / Ubuntu LTS Crash Investigation # Steam Deck / Ubuntu LTS Crash Investigation
## Status: UNSOLVED ## Status: SOLVED
**Last updated:** 2026-01-27 **Last updated:** 2026-01-27
No stable solution found. jemalloc helps occasionally but crashes still occur randomly. **Solution:** Replace bundled `libzstd.so` with system version.
--- ---
@@ -30,96 +30,81 @@ The crash occurs after successful authentication, specifically right after "Fini
- Windows - Windows
- Older Arch Linux (glibc < 2.41) - Older Arch Linux (glibc < 2.41)
**Critical Finding:** The UNPATCHED original binary works fine on Steam Deck. The crash is caused by ANY binary patching. ---
## Root Cause
The **bundled `libzstd.so`** in the game client is incompatible with glibc 2.41's stricter heap validation. When the game decompresses assets using this library, it triggers heap corruption detected by glibc 2.41.
The crash occurs in `libzstd.so` during `free()` after "Finished handling RequiredAssets" (asset decompression).
--- ---
## What Was Tried (All Failed) ## Solution
### Memory Allocators Replace the bundled `libzstd.so` with the system's `libzstd.so.1`.
| Approach | Result |
|----------|--------|
| `LD_PRELOAD=/usr/lib/libjemalloc.so.2` | Works randomly (3/10 times), not stable |
| `MALLOC_CHECK_=0` | No effect |
| `MALLOC_PERTURB_=255` | No effect |
| `GLIBC_TUNABLES=glibc.malloc.tcache_count=0` | No effect |
### Process/Scheduling ### Automatic (Launcher)
| Approach | Result |
|----------|--------|
| `taskset -c 0` (single core) | Game too slow, stuck at connecting |
| `taskset -c 0,1` or `0-3` | Still crashes |
| `nice -n 19` | No effect |
| `chrt --idle 0` | No effect |
| `strace -f` | No effect |
### Linker/Loading The launcher automatically detects and replaces `libzstd.so` on Linux systems. No manual action needed.
| Approach | Result |
|----------|--------|
| `LD_BIND_NOW=1` | No effect |
| Wrapper script with LD_PRELOAD | No effect |
| Shell spawn with inline LD_PRELOAD | No effect |
### Patching Variations ### Manual
| Approach | Result |
|----------|--------|
| Null-padding after replacement | Crashes (made it worse) |
| No null-padding (develop behavior) | Still crashes |
| Minimal patches (3 instead of 6) | Still crashes |
| Ultra-minimal (1 patch - domain only) | Still crashes |
| Skip sentry patch | Still crashes |
| Skip subdomain patches | Still crashes |
**Key Finding:** Even patching just 1 string (main domain only) causes the crash. ```bash
cd ~/.hytalef2p/release/package/game/latest/Client
--- # Backup bundled version
mv libzstd.so libzstd.so.bundled
## String Occurrences Found # Link to system version
# Steam Deck / Arch Linux:
ln -s /usr/lib/libzstd.so.1 libzstd.so
### Length-Prefixed Format # Debian / Ubuntu:
Found by default patcher mode: ln -s /usr/lib/x86_64-linux-gnu/libzstd.so.1 libzstd.so
| Offset | Content | Notes | # Fedora / RHEL:
|--------|---------|-------| ln -s /usr/lib64/libzstd.so.1 libzstd.so
| 0x1bc5d63 | `hytale.com` | **Surrounded by x86 code!** |
### UTF-16LE Format (3 occurrences)
| Offset | Content |
|--------|---------|
| 0x1bc5ad7 | `sentry.hytale.com/...` |
| 0x1bc5b3f | `https://hytale.com/help...` |
| 0x1bc5bc9 | `store.hytale.com/?...` |
---
## Binary Analysis
When patching with length-prefixed mode:
```
< 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:** ### Restore Original
```
5933 b8 | 0a000000 | h.y.t.a.l.e...c.o.m | 8933 8807 0000
???????? | len=10 | string content | mov [rbx],esi?
```
- `5933 b8` before string - could be code or metadata ```bash
- `0a 00 00 00` - .NET length prefix (10 characters) cd ~/.hytalef2p/release/package/game/latest/Client
- String content in UTF-16LE rm libzstd.so
- `89 33` after - this is `mov [rbx], esi` in x86-64! mv libzstd.so.bundled libzstd.so
```
**The string is embedded near executable code, not in a clean data section.**
--- ---
## GDB Stack Trace ## Why This Works
1. The bundled `libzstd.so` was likely compiled with different allocator settings or an older toolchain
2. glibc 2.41 has stricter heap validation that catches invalid memory operations
3. The system `libzstd.so.1` is compiled with the system's glibc and uses compatible memory allocation patterns
4. By using the system library, we avoid the incompatibility entirely
---
## Previous Investigation (for reference)
### What Was Tried Before Finding Solution
| Approach | Result |
|----------|--------|
| jemalloc allocator | Worked ~30% of time, not stable |
| GLIBC_TUNABLES | No effect |
| taskset (CPU pinning) | Single core too slow |
| nice/chrt (scheduling) | No effect |
| Various patching approaches | All crashed |
### Key Insight
The crash was in `libzstd.so`, not in our patched code. The patching just changed timing enough to expose the libzstd incompatibility more frequently.
---
## GDB Stack Trace (Historical)
``` ```
#0 0x00007ffff7d3f5a4 in ?? () from /usr/lib/libc.so.6 #0 0x00007ffff7d3f5a4 in ?? () from /usr/lib/libc.so.6
@@ -127,105 +112,12 @@ When patching with length-prefixed mode:
#2 abort () from /usr/lib/libc.so.6 #2 abort () from /usr/lib/libc.so.6
#3-#4 ?? () from /usr/lib/libc.so.6 #3-#4 ?? () from /usr/lib/libc.so.6
#5 free () from /usr/lib/libc.so.6 #5 free () from /usr/lib/libc.so.6
#6 ?? () from libzstd.so <-- CRASH POINT #6 ?? () from libzstd.so <-- CRASH POINT (bundled library)
#7-#24 HytaleClient code (asset decompression) #7-#24 HytaleClient code (asset decompression)
``` ```
Crash occurs in `libzstd.so` during `free()` after "Finished handling RequiredAssets".
--- ---
## Hypotheses ## Branch
### 1. .NET AOT String Metadata (Most Likely) `fix/steamdeck-libzstd`
.NET AOT may have precomputed hashes, checksums, or relocation info for strings. Modifying string content breaks internal consistency, causing memory corruption when the runtime tries to use related data structures.
### 2. Code/Data Interleaving
The strings are embedded near x86 code (`89 33` = `mov [rbx], esi`). .NET AOT may use relative offsets that get invalidated when we modify nearby bytes.
### 3. Binary Checksums
The binary may have integrity checks for certain sections that we're invalidating by patching.
### 4. Timing-Dependent Race Condition
The fact that it works randomly (~30% of the time with jemalloc) suggests a race condition that's affected by:
- Memory layout changes from patching
- Allocator behavior differences
- CPU scheduling
---
## Valgrind Results (Misleading)
- Valgrind showed NO memory corruption errors
- Game ran successfully under Valgrind (slower execution)
- This suggested jemalloc would fix it, but it doesn't consistently work
The slowdown from Valgrind likely masks the race condition timing.
---
## Current Launcher Implementation
The launcher attempts:
1. Auto-detect jemalloc at common paths
2. Auto-install jemalloc via pkexec if not found
3. Launch game with `LD_PRELOAD` via shell command
But this doesn't provide stable results.
---
## Potential Alternative Approaches (Not Yet Tried)
### 1. LD_PRELOAD Network Hooking
Instead of patching the binary, hook `getaddrinfo()` / `connect()` to redirect network calls at runtime. No binary modification needed.
### 2. Local Proxy + Certificate
Run a local HTTPS proxy that intercepts hytale.com traffic and redirects to custom server. Requires installing a custom CA certificate.
### 3. DNS + iptables Redirect
Use local DNS to resolve hytale.com to localhost, then iptables to redirect to actual custom server. Requires root/sudo.
### 4. Container with Older glibc
Run the game in a container with glibc < 2.41 where the stricter validation doesn't exist.
### 5. Different Patching Location
Find strings in a pure data section rather than code-adjacent areas.
---
## Files Reference
**Binary:** `HytaleClient` (ELF 64-bit, ~39.9 MB)
**Branch:** `fix/steamdeck-jemalloc-crash`
---
## Install jemalloc (Partial Mitigation)
jemalloc may help in some cases (~30% success rate):
```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. To disable:
```bash
HYTALE_NO_JEMALLOC=1 npm start
```
---
## Conclusion
**No stable solution found.** The binary patching approach may be fundamentally incompatible with glibc 2.41's stricter heap validation when modifying .NET AOT compiled binaries.
Alternative approaches (network hooking, proxy, container) may be required for reliable Steam Deck / Ubuntu LTS support.

View File

@@ -1,95 +1,65 @@
# Steam Deck / Linux Crash Fix # Steam Deck / Linux Crash Fix
## SOLUTION: Use jemalloc ✓ ## SOLUTION: Use system libzstd
The crash is caused by glibc 2.41's stricter heap validation. Using jemalloc as the memory allocator fixes the issue. The crash is caused by the bundled `libzstd.so` being incompatible with glibc 2.41's stricter heap validation.
### Install jemalloc ### Automatic Fix
The launcher automatically replaces `libzstd.so` with the system version. No manual action needed.
### Manual Fix
```bash ```bash
# Steam Deck / Arch Linux cd ~/.hytalef2p/release/package/game/latest/Client
sudo pacman -S jemalloc
# Ubuntu / Debian # Backup and replace
sudo apt install libjemalloc2 mv libzstd.so libzstd.so.bundled
ln -s /usr/lib/libzstd.so.1 libzstd.so
# Fedora / RHEL
sudo dnf install jemalloc
``` ```
### Launcher Auto-Detection ### Restore Original
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 ```bash
cd ~/.hytalef2p/release/package/game/latest cd ~/.hytalef2p/release/package/game/latest/Client
rm libzstd.so
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 mv libzstd.so.bundled libzstd.so
``` ```
--- ---
## Debug Commands (for troubleshooting) ## Debug Commands (for troubleshooting)
### Base Command ### Check libzstd Status
```bash ```bash
cd ~/.hytalef2p/release/package/game/latest # Check if symlinked
``` ls -la ~/.hytalef2p/release/package/game/latest/Client/libzstd.so
### GDB Stack Trace (for crash analysis) # Find system libzstd
```bash find /usr/lib -name "libzstd.so*"
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 ### Binary Validation
```bash ```bash
file ~/.hytalef2p/release/package/game/latest/Client/HytaleClient file ~/.hytalef2p/release/package/game/latest/Client/HytaleClient
ldd ~/.hytalef2p/release/package/game/latest/Client/HytaleClient ldd ~/.hytalef2p/release/package/game/latest/Client/HytaleClient
``` ```
### Hex Dump Commands ### Restore Client Binary
```bash ```bash
# Search for hytale.com UTF-16LE cd ~/.hytalef2p/release/package/game/latest/Client
xxd ~/.hytalef2p/release/package/game/latest/Client/HytaleClient.original | grep "6800 7900 7400 6100 6c00 6500 2e00 6300 6f00 6d00" cp HytaleClient.original HytaleClient
rm -f HytaleClient.patched_custom
``` ```
--- ---
## Test Different Patch Modes ## Environment Variables
```bash | Variable | Description | Example |
# Restore original |----------|-------------|---------|
cp ~/.hytalef2p/release/package/game/latest/Client/HytaleClient.original ~/.hytalef2p/release/package/game/latest/Client/HytaleClient | `HYTALE_AUTH_DOMAIN` | Custom auth domain | `auth.sanasol.ws` |
rm ~/.hytalef2p/release/package/game/latest/Client/HytaleClient.patched_custom | `HYTALE_NO_LIBZSTD_FIX` | Disable libzstd replacement | `1` |
# 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
```

66
main.js
View File

@@ -176,7 +176,8 @@ function createWindow() {
initDiscordRPC(); initDiscordRPC();
// Configure and initialize electron-updater // Configure and initialize electron-updater
autoUpdater.autoDownload = false; // Enable auto-download so updates start immediately when available
autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = true; autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.on('checking-for-update', () => { autoUpdater.on('checking-for-update', () => {
@@ -201,6 +202,20 @@ function createWindow() {
autoUpdater.on('error', (err) => { autoUpdater.on('error', (err) => {
console.error('Error in auto-updater:', err); console.error('Error in auto-updater:', err);
// Handle macOS code signing errors - requires manual download
if (mainWindow && !mainWindow.isDestroyed()) {
const isMacSigningError = process.platform === 'darwin' &&
(err.code === 'ERR_UPDATER_INVALID_SIGNATURE' ||
err.message.includes('signature') ||
err.message.includes('code sign'));
mainWindow.webContents.send('update-error', {
message: err.message,
isMacSigningError: isMacSigningError,
requiresManualDownload: isMacSigningError || process.platform === 'darwin'
});
}
}); });
autoUpdater.on('download-progress', (progressObj) => { autoUpdater.on('download-progress', (progressObj) => {
@@ -218,7 +233,10 @@ function createWindow() {
console.log('Update downloaded:', info.version); console.log('Update downloaded:', info.version);
if (mainWindow && !mainWindow.isDestroyed()) { if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('update-downloaded', { mainWindow.webContents.send('update-downloaded', {
version: info.version version: info.version,
platform: process.platform,
// macOS auto-install often fails on unsigned apps
autoInstallSupported: process.platform !== 'darwin'
}); });
} }
}); });
@@ -859,6 +877,17 @@ ipcMain.handle('open-external', async (event, url) => {
} }
}); });
ipcMain.handle('open-download-page', async () => {
try {
// Open GitHub releases page for manual download
await shell.openExternal('https://github.com/amiayweb/Hytale-F2P/releases/latest');
return { success: true };
} catch (error) {
console.error('Failed to open download page:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle('open-game-location', async () => { ipcMain.handle('open-game-location', async () => {
try { try {
const { getResolvedAppDir, loadVersionBranch } = require('./backend/launcher'); const { getResolvedAppDir, loadVersionBranch } = require('./backend/launcher');
@@ -1086,8 +1115,37 @@ ipcMain.handle('download-update', async () => {
} }
}); });
ipcMain.handle('install-update', () => { ipcMain.handle('install-update', async () => {
autoUpdater.quitAndInstall(false, true); console.log('[AutoUpdater] Installing update...');
// On macOS, quitAndInstall often fails silently
// Use a more aggressive approach
if (process.platform === 'darwin') {
console.log('[AutoUpdater] macOS detected, using force quit approach');
// Give user feedback that something is happening
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('update-installing');
}
// Small delay to show the "Installing..." state
await new Promise(resolve => setTimeout(resolve, 500));
try {
autoUpdater.quitAndInstall(false, true);
} catch (err) {
console.error('[AutoUpdater] quitAndInstall failed:', err);
// Force quit the app - the update should install on next launch
app.exit(0);
}
// If quitAndInstall didn't work, force exit after a delay
setTimeout(() => {
console.log('[AutoUpdater] Force exiting app...');
app.exit(0);
}, 2000);
} else {
autoUpdater.quitAndInstall(false, true);
}
}); });
ipcMain.handle('get-launcher-version', () => { ipcMain.handle('get-launcher-version', () => {

5
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "hytale-f2p-launcher", "name": "hytale-f2p-launcher",
"version": "2.1.1", "version": "2.1.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "hytale-f2p-launcher", "name": "hytale-f2p-launcher",
"version": "2.1.1", "version": "2.1.2",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"adm-zip": "^0.5.10", "adm-zip": "^0.5.10",
@@ -19,6 +19,7 @@
"uuid": "^9.0.1" "uuid": "^9.0.1"
}, },
"devDependencies": { "devDependencies": {
"@electron/notarize": "^2.5.0",
"electron": "^40.0.0", "electron": "^40.0.0",
"electron-builder": "^26.4.0" "electron-builder": "^26.4.0"
} }

View File

@@ -45,6 +45,7 @@
}, },
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@electron/notarize": "^2.5.0",
"electron": "^40.0.0", "electron": "^40.0.0",
"electron-builder": "^26.4.0" "electron-builder": "^26.4.0"
}, },
@@ -131,8 +132,13 @@
} }
], ],
"icon": "build/icon.icns", "icon": "build/icon.icns",
"category": "public.app-category.games" "category": "public.app-category.games",
"hardenedRuntime": true,
"gatekeeperAssess": false,
"entitlements": "build/entitlements.mac.plist",
"entitlementsInherit": "build/entitlements.mac.plist"
}, },
"afterSign": "scripts/notarize.js",
"nsis": { "nsis": {
"oneClick": false, "oneClick": false,
"allowToChangeInstallationDirectory": true, "allowToChangeInstallationDirectory": true,

View File

@@ -24,7 +24,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
saveCloseLauncher: (enabled) => ipcRenderer.invoke('save-close-launcher', enabled), saveCloseLauncher: (enabled) => ipcRenderer.invoke('save-close-launcher', enabled),
loadCloseLauncher: () => ipcRenderer.invoke('load-close-launcher'), loadCloseLauncher: () => ipcRenderer.invoke('load-close-launcher'),
// Harwadre Acceleration // Hardware Acceleration
saveLauncherHardwareAcceleration: (enabled) => ipcRenderer.invoke('save-launcher-hw-accel', enabled), saveLauncherHardwareAcceleration: (enabled) => ipcRenderer.invoke('save-launcher-hw-accel', enabled),
loadLauncherHardwareAcceleration: () => ipcRenderer.invoke('load-launcher-hw-accel'), loadLauncherHardwareAcceleration: () => ipcRenderer.invoke('load-launcher-hw-accel'),
@@ -50,14 +50,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
selectModFiles: () => ipcRenderer.invoke('select-mod-files'), selectModFiles: () => ipcRenderer.invoke('select-mod-files'),
copyModFile: (sourcePath, modsPath) => ipcRenderer.invoke('copy-mod-file', sourcePath, modsPath), copyModFile: (sourcePath, modsPath) => ipcRenderer.invoke('copy-mod-file', sourcePath, modsPath),
onProgressUpdate: (callback) => { onProgressUpdate: (callback) => {
ipcRenderer.on('progress-update', (event, data) => { ipcRenderer.on('progress-update', (event, data) => callback(data));
// Ensure data includes retry state if available
if (data && typeof data === 'object') {
callback(data);
} else {
callback(data);
}
});
}, },
onProgressComplete: (callback) => { onProgressComplete: (callback) => {
ipcRenderer.on('progress-complete', () => callback()); ipcRenderer.on('progress-complete', () => callback());
@@ -69,7 +62,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.on('installation-end', () => callback()); ipcRenderer.on('installation-end', () => callback());
}, },
getUserId: () => ipcRenderer.invoke('get-user-id'), getUserId: () => ipcRenderer.invoke('get-user-id'),
checkForUpdates: () => ipcRenderer.invoke('check-for-updates'),
openDownloadPage: () => ipcRenderer.invoke('open-download-page'), openDownloadPage: () => ipcRenderer.invoke('open-download-page'),
getUpdateInfo: () => ipcRenderer.invoke('get-update-info'), getUpdateInfo: () => ipcRenderer.invoke('get-update-info'),
onUpdatePopup: (callback) => { onUpdatePopup: (callback) => {
@@ -126,6 +118,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
checkForUpdates: () => ipcRenderer.invoke('check-for-updates'), checkForUpdates: () => ipcRenderer.invoke('check-for-updates'),
downloadUpdate: () => ipcRenderer.invoke('download-update'), downloadUpdate: () => ipcRenderer.invoke('download-update'),
installUpdate: () => ipcRenderer.invoke('install-update'), installUpdate: () => ipcRenderer.invoke('install-update'),
quitAndInstallUpdate: () => ipcRenderer.invoke('install-update'), // Alias for update.js compatibility
getLauncherVersion: () => ipcRenderer.invoke('get-launcher-version'), getLauncherVersion: () => ipcRenderer.invoke('get-launcher-version'),
onUpdateAvailable: (callback) => { onUpdateAvailable: (callback) => {
ipcRenderer.on('update-available', (event, data) => callback(data)); ipcRenderer.on('update-available', (event, data) => callback(data));

62
scripts/notarize.js Normal file
View File

@@ -0,0 +1,62 @@
console.log('[Notarize] Script loaded');
let notarize;
try {
notarize = require('@electron/notarize').notarize;
console.log('[Notarize] @electron/notarize loaded successfully');
} catch (err) {
console.error('[Notarize] Failed to load @electron/notarize:', err.message);
throw err;
}
const path = require('path');
exports.default = async function notarizing(context) {
console.log('[Notarize] afterSign hook called');
console.log('[Notarize] Context:', JSON.stringify({
platform: context.electronPlatformName,
appOutDir: context.appOutDir,
outDir: context.outDir
}, null, 2));
const { electronPlatformName, appOutDir } = context;
// Only notarize macOS builds
if (electronPlatformName !== 'darwin') {
console.log('[Notarize] Skipping: not macOS');
return;
}
// Check credentials
const hasAppleId = !!process.env.APPLE_ID;
const hasPassword = !!process.env.APPLE_APP_SPECIFIC_PASSWORD;
const hasTeamId = !!process.env.APPLE_TEAM_ID;
console.log('[Notarize] Credentials check:', { hasAppleId, hasPassword, hasTeamId });
if (!hasAppleId || !hasPassword || !hasTeamId) {
console.log('[Notarize] Skipping: missing credentials');
return;
}
const appName = context.packager.appInfo.productFilename;
const appPath = path.join(appOutDir, `${appName}.app`);
console.log('[Notarize] Starting notarization...');
console.log('[Notarize] App path:', appPath);
console.log('[Notarize] Team ID:', process.env.APPLE_TEAM_ID);
try {
await notarize({
appPath,
appleId: process.env.APPLE_ID,
appleIdPassword: process.env.APPLE_APP_SPECIFIC_PASSWORD,
teamId: process.env.APPLE_TEAM_ID,
});
console.log('[Notarize] Notarization complete!');
} catch (error) {
console.error('[Notarize] Notarization failed:', error.message);
console.error('[Notarize] Full error:', error);
throw error;
}
};