Compare commits

...

11 Commits

Author SHA1 Message Date
sanasol
ab6f932245 Add HYTALE_NOOP_TEST to test read/write without patching
Tests if our file read/write process itself causes corruption.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 04:05:08 +01:00
sanasol
6333263ef9 Fix: Don't fallback to legacy mode when using skip list
When HYTALE_PATCH_SKIP is set, the legacy fallback was ignoring
the skip list and patching all occurrences anyway.

Now if skip list is active or HYTALE_NO_LEGACY_FALLBACK=1,
the legacy fallback is disabled.

Also found 4th occurrence at 0x1bc5d67 with metadata byte 0x89
that legacy mode was patching - this may be the crash culprit.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 03:51:32 +01:00
sanasol
654deca933 Add HYTALE_PATCH_SKIP to skip specific occurrences by index
HYTALE_PATCH_SKIP=0 - skip first occurrence only
HYTALE_PATCH_SKIP=0,2 - skip first and third
HYTALE_PATCH_SKIP=0 HYTALE_PATCH_LIMIT=1 - patch only second occurrence

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 03:38:09 +01:00
sanasol
aab67e8e28 Add HYTALE_PATCH_LIMIT to patch only first N occurrences
Use HYTALE_PATCH_LIMIT=1 to patch only first occurrence,
HYTALE_PATCH_LIMIT=2 for first two, etc.

Helps isolate which specific occurrence causes the crash.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 03:34:36 +01:00
sanasol
3953827f4a Add offset logging to debug which locations are being patched
Shows hex offset, bytes before/after each patch location to help
identify if we're patching false positives.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 03:31:51 +01:00
sanasol
81d1e7c113 Add HYTALE_PATCH_MODE env var to test different string formats
HYTALE_PATCH_MODE=utf16le - Use pure UTF-16LE (no length prefix)
HYTALE_PATCH_MODE=length-prefixed - Use length-prefixed format (default)

This helps debug if the length-prefixed format is causing crashes.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 03:00:29 +01:00
sanasol
d8f90bd1ff Disable Discord URL patching entirely
The Discord URL patch was causing buffer overflow crashes and has no
practical effect on F2P functionality anyway.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 02:44:13 +01:00
sanasol
50491abc69 Fix buffer overflow in Discord URL patch - likely cause of crashes
The Discord URL patch was writing 28 bytes (.gg/MHkEjepMQ7, 14 chars)
where only 20 bytes existed (.gg/hytale, 10 chars), corrupting 8 bytes
of adjacent data in the binary.

Changes:
- Use same-length Discord URL: .gg/santop (10 chars)
- Add length check to UTF-16LE fallback path
- Add length check and zero-fill to findAndReplaceDomainSmart

This buffer overflow explains why the crash happened on some systems
(Steam Deck, Ubuntu LTS) but not others - depending on what data
was adjacent to the patched string.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 02:41:38 +01:00
sanasol
c92c5bec3c Add debug options and jemalloc support for Linux memory issues
Debug env vars for clientPatcher:
- HYTALE_SKIP_SENTRY_PATCH=1 - Skip sentry URL patch (60->26 chars)
- HYTALE_SKIP_SUBDOMAIN_PATCH=1 - Skip subdomain prefix patches

Game launcher Linux options:
- HYTALE_USE_JEMALLOC=1 - Use jemalloc allocator instead of glibc

This helps isolate which patch causes "free(): invalid pointer"
on Steam Deck and Ubuntu LTS.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 02:31:38 +01:00
sanasol
73f67f2fec Add MALLOC_CHECK_=0 for Linux to bypass glibc heap validation
Attempts to fix "free(): invalid pointer" crashes on Steam Deck and
Ubuntu LTS. The same patched binary works on macOS, Windows, and Arch
Linux, suggesting the issue is glibc's strict heap validation rather
than actual memory corruption.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 02:27:04 +01:00
sanasol
2582d9b6d1 Fix memory corruption by null-padding shorter replacement patterns
When replacing domain strings with shorter ones, the replaceBytes function
was only copying the new bytes without clearing the leftover bytes from
the old pattern. This caused "free(): invalid pointer" crashes on Steam
Deck and Ubuntu due to corrupted string metadata in the .NET AOT binary.

Fix: Fill the entire old pattern region with 0x00 before writing the
new bytes. This ensures no leftover data remains that could corrupt
the binary structure.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 02:10:31 +01:00
2 changed files with 204 additions and 65 deletions

View File

@@ -285,6 +285,32 @@ 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

@@ -147,8 +147,9 @@ class ClientPatcher {
}
/**
* Replace bytes in buffer - only overwrites the length of new bytes
* Prevents offset corruption by not expanding the replacement
* Replace bytes in buffer with null-padding for shorter replacements
* When new pattern is shorter than old, pads with 0x00 to prevent leftover bytes
* that can cause memory corruption (free(): invalid pointer) on some systems
*/
replaceBytes(buffer, oldBytes, newBytes) {
let count = 0;
@@ -162,7 +163,20 @@ class ClientPatcher {
const positions = this.findAllOccurrences(result, oldBytes);
for (const pos of positions) {
// Only overwrite the length of the new bytes
// 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
newBytes.copy(result, pos);
count++;
}
@@ -170,6 +184,65 @@ 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.
@@ -197,11 +270,19 @@ 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));
@@ -217,6 +298,11 @@ 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;
@@ -239,6 +325,10 @@ 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);
@@ -247,50 +337,79 @@ class ClientPatcher {
console.log(` Patching strategy: ${strategy.description}`);
// 1. Patch telemetry/sentry URL
const oldSentry = 'https://ca900df42fcf57d4dd8401a86ddd7da2@sentry.hytale.com/2';
const newSentry = `${protocol}t@${domain}/2`;
// 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`;
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}`);
const domainResult = this.replaceBytes(
result,
this.stringToLengthPrefixed(ORIGINAL_DOMAIN),
this.stringToLengthPrefixed(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)
);
}
result = domainResult.buffer;
if (domainResult.count > 0) {
console.log(` Replaced ${domainResult.count} domain occurrence(s)`);
totalCount += domainResult.count;
}
// 3. Patch subdomain prefixes
const subdomains = ['https://tools.', 'https://sessions.', 'https://account-data.', 'https://telemetry.'];
const newSubdomainPrefix = protocol + strategy.subdomainPrefix;
// 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;
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;
}
}
}
@@ -298,38 +417,13 @@ class ClientPatcher {
}
/**
* Patch Discord invite URLs from .gg/hytale to .gg/MHkEjepMQ7
* 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
*/
patchDiscordUrl(data) {
let count = 0;
const result = Buffer.from(data);
const oldUrl = '.gg/hytale';
const newUrl = '.gg/MHkEjepMQ7';
// Try length-prefixed format first
const lpResult = this.replaceBytes(
result,
this.stringToLengthPrefixed(oldUrl),
this.stringToLengthPrefixed(newUrl)
);
if (lpResult.count > 0) {
return { buffer: lpResult.buffer, count: lpResult.count };
}
// Fallback to UTF-16LE
const oldUtf16 = this.stringToUtf16LE(oldUrl);
const newUtf16 = this.stringToUtf16LE(newUrl);
const positions = this.findAllOccurrences(result, oldUtf16);
for (const pos of positions) {
newUtf16.copy(result, pos);
count++;
}
return { buffer: result, count };
// Disabled - no practical effect and was causing memory corruption
return { buffer: Buffer.from(data), count: 0 };
}
/**
@@ -483,6 +577,14 @@ 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);
@@ -490,6 +592,17 @@ 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