mirror of
https://git.sanhost.net/sanasol/hytale-f2p
synced 2026-03-07 07:01:50 -03:00
MAESTRO: harden patcher replacements for multi-binary installs
This commit is contained in:
36
backend/utils/__tests__/clientPatcher.test.js
Normal file
36
backend/utils/__tests__/clientPatcher.test.js
Normal file
@@ -0,0 +1,36 @@
|
||||
const assert = require('assert');
|
||||
const clientPatcher = require('../clientPatcher');
|
||||
|
||||
function bytesAreZero(buffer) {
|
||||
for (const byte of buffer) {
|
||||
if (byte !== 0x00) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const directStrategy = clientPatcher.getDomainStrategy('authws.net');
|
||||
assert.strictEqual(directStrategy.mode, 'direct');
|
||||
assert.strictEqual(directStrategy.mainDomain, 'authws.net');
|
||||
assert.strictEqual(directStrategy.subdomainPrefix, '');
|
||||
|
||||
const splitStrategy = clientPatcher.getDomainStrategy('auth.sanasol.ws');
|
||||
assert.strictEqual(splitStrategy.mode, 'split');
|
||||
assert.strictEqual(splitStrategy.subdomainPrefix, 'auth.s');
|
||||
assert.strictEqual(splitStrategy.mainDomain, 'anasol.ws');
|
||||
|
||||
const oldSubdomain = clientPatcher.stringToLengthPrefixed('https://tools.');
|
||||
const newSubdomain = clientPatcher.stringToLengthPrefixed('https://');
|
||||
const replaceResult = clientPatcher.replaceBytes(oldSubdomain, oldSubdomain, newSubdomain);
|
||||
const padding = replaceResult.buffer.slice(newSubdomain.length, oldSubdomain.length);
|
||||
assert.ok(bytesAreZero(padding), 'Expected null padding after shorter replacement');
|
||||
|
||||
const oldDomainUtf16 = clientPatcher.stringToUtf16LE('hytale.com');
|
||||
const smartResult = clientPatcher.findAndReplaceDomainSmart(oldDomainUtf16, 'hytale.com', 'auth.ws');
|
||||
const newDomainUtf16 = clientPatcher.stringToUtf16LE('auth.ws');
|
||||
assert.ok(smartResult.buffer.slice(0, newDomainUtf16.length).equals(newDomainUtf16));
|
||||
const smartPadding = smartResult.buffer.slice(newDomainUtf16.length, oldDomainUtf16.length);
|
||||
assert.ok(bytesAreZero(smartPadding), 'Expected null padding in smart replacement');
|
||||
|
||||
console.log('clientPatcher tests passed');
|
||||
@@ -132,9 +132,10 @@ class ClientPatcher {
|
||||
/**
|
||||
* Replace bytes in buffer - only overwrites the length of new bytes
|
||||
*/
|
||||
replaceBytes(buffer, oldBytes, newBytes) {
|
||||
replaceBytes(buffer, oldBytes, newBytes, options = {}) {
|
||||
let count = 0;
|
||||
const result = Buffer.from(buffer);
|
||||
const padNulls = options.padNulls !== false;
|
||||
|
||||
if (newBytes.length > oldBytes.length) {
|
||||
console.warn(` Warning: New pattern (${newBytes.length}) longer than old (${oldBytes.length}), skipping`);
|
||||
@@ -144,6 +145,9 @@ class ClientPatcher {
|
||||
const positions = this.findAllOccurrences(result, oldBytes);
|
||||
for (const pos of positions) {
|
||||
newBytes.copy(result, pos);
|
||||
if (padNulls && newBytes.length < oldBytes.length) {
|
||||
result.fill(0x00, pos + newBytes.length, pos + oldBytes.length);
|
||||
}
|
||||
count++;
|
||||
}
|
||||
|
||||
@@ -173,14 +177,21 @@ class ClientPatcher {
|
||||
|
||||
if (lastCharFirstByte === oldLastCharByte) {
|
||||
newUtf16NoLast.copy(result, pos);
|
||||
result[lastCharPos] = newLastCharByte;
|
||||
const newLastCharPos = pos + newUtf16NoLast.length;
|
||||
const secondByte = lastCharPos + 1 < result.length ? result[lastCharPos + 1] : 0x00;
|
||||
const isUtf16 = secondByte === 0x00;
|
||||
if (newUtf16NoLast.length < oldUtf16NoLast.length) {
|
||||
const padEnd = isUtf16 ? lastCharPos + 2 : pos + oldUtf16NoLast.length;
|
||||
result.fill(0x00, newLastCharPos + 1, padEnd);
|
||||
}
|
||||
result[newLastCharPos] = newLastCharByte;
|
||||
|
||||
if (lastCharPos + 1 < result.length) {
|
||||
const secondByte = result[lastCharPos + 1];
|
||||
if (secondByte === 0x00) {
|
||||
if (newLastCharPos + 1 < result.length) {
|
||||
const nextByte = result[newLastCharPos + 1];
|
||||
if (nextByte === 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)})`);
|
||||
console.log(` Patched length-prefixed occurrence at offset 0x${pos.toString(16)} (metadata: 0x${nextByte.toString(16)})`);
|
||||
}
|
||||
}
|
||||
count++;
|
||||
@@ -190,6 +201,51 @@ class ClientPatcher {
|
||||
return { buffer: result, count };
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply all domain patches using legacy UTF-16LE format
|
||||
*/
|
||||
applyLegacyPatches(data, domain, protocol = 'https://') {
|
||||
let result = Buffer.from(data);
|
||||
let totalCount = 0;
|
||||
const strategy = this.getDomainStrategy(domain);
|
||||
|
||||
console.log(` Legacy strategy: ${strategy.description}`);
|
||||
|
||||
const oldSentry = 'https://ca900df42fcf57d4dd8401a86ddd7da2@sentry.hytale.com/2';
|
||||
const newSentry = `${protocol}t@${domain}/2`;
|
||||
const sentryResult = this.replaceBytes(
|
||||
result,
|
||||
this.stringToUtf16LE(oldSentry),
|
||||
this.stringToUtf16LE(newSentry)
|
||||
);
|
||||
result = sentryResult.buffer;
|
||||
if (sentryResult.count > 0) {
|
||||
console.log(` Replaced ${sentryResult.count} legacy sentry occurrence(s)`);
|
||||
totalCount += sentryResult.count;
|
||||
}
|
||||
|
||||
const domainResult = this.findAndReplaceDomainSmart(result, ORIGINAL_DOMAIN, strategy.mainDomain);
|
||||
result = domainResult.buffer;
|
||||
if (domainResult.count > 0) {
|
||||
console.log(` Replaced ${domainResult.count} legacy domain occurrence(s)`);
|
||||
totalCount += domainResult.count;
|
||||
}
|
||||
|
||||
const subdomains = ['https://tools.', 'https://sessions.', 'https://account-data.', 'https://telemetry.'];
|
||||
const newSubdomainPrefix = protocol + strategy.subdomainPrefix;
|
||||
|
||||
for (const sub of subdomains) {
|
||||
const subResult = this.findAndReplaceDomainSmart(result, sub, newSubdomainPrefix);
|
||||
result = subResult.buffer;
|
||||
if (subResult.count > 0) {
|
||||
console.log(` Replaced ${subResult.count} legacy subdomain occurrence(s)`);
|
||||
totalCount += subResult.count;
|
||||
}
|
||||
}
|
||||
|
||||
return { buffer: result, count: totalCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply all domain patches using length-prefixed format
|
||||
*/
|
||||
@@ -472,7 +528,7 @@ class ClientPatcher {
|
||||
if (count === 0 && discordCount === 0) {
|
||||
console.log('No occurrences found - trying legacy UTF-16LE format...');
|
||||
|
||||
const legacyResult = this.findAndReplaceDomainSmart(data, ORIGINAL_DOMAIN, strategy.mainDomain);
|
||||
const legacyResult = this.applyLegacyPatches(data, newDomain);
|
||||
if (legacyResult.count > 0) {
|
||||
console.log(`Found ${legacyResult.count} occurrences with legacy format`);
|
||||
fs.writeFileSync(clientPath, legacyResult.buffer);
|
||||
@@ -645,23 +701,64 @@ class ClientPatcher {
|
||||
* Find client binary path based on platform
|
||||
*/
|
||||
findClientPath(gameDir) {
|
||||
const candidates = this.findClientBinaries(gameDir);
|
||||
return candidates.length > 0 ? candidates[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all client binaries that may contain patchable strings
|
||||
*/
|
||||
findClientBinaries(gameDir) {
|
||||
const candidates = [];
|
||||
const seen = new Set();
|
||||
const clientDir = path.join(gameDir, 'Client');
|
||||
|
||||
const addCandidate = (candidate) => {
|
||||
if (!candidate || seen.has(candidate)) {
|
||||
return;
|
||||
}
|
||||
if (fs.existsSync(candidate)) {
|
||||
candidates.push(candidate);
|
||||
seen.add(candidate);
|
||||
}
|
||||
};
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
candidates.push(path.join(gameDir, 'Client', 'Hytale.app', 'Contents', 'MacOS', 'HytaleClient'));
|
||||
candidates.push(path.join(gameDir, 'Client', 'HytaleClient'));
|
||||
const appBundle = path.join(clientDir, 'Hytale.app');
|
||||
const appMacosDir = path.join(appBundle, 'Contents', 'MacOS');
|
||||
|
||||
addCandidate(path.join(appMacosDir, 'HytaleClient'));
|
||||
addCandidate(path.join(clientDir, 'HytaleClient'));
|
||||
|
||||
if (fs.existsSync(appMacosDir)) {
|
||||
const entries = fs.readdirSync(appMacosDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile()) {
|
||||
addCandidate(path.join(appMacosDir, entry.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (process.platform === 'win32') {
|
||||
candidates.push(path.join(gameDir, 'Client', 'HytaleClient.exe'));
|
||||
addCandidate(path.join(clientDir, 'HytaleClient.exe'));
|
||||
addCandidate(path.join(clientDir, 'HytaleClient.dll'));
|
||||
|
||||
if (fs.existsSync(clientDir)) {
|
||||
const entries = fs.readdirSync(clientDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
const lowerName = entry.name.toLowerCase();
|
||||
if (lowerName.endsWith('.dll')) {
|
||||
addCandidate(path.join(clientDir, entry.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
candidates.push(path.join(gameDir, 'Client', 'HytaleClient'));
|
||||
addCandidate(path.join(clientDir, 'HytaleClient'));
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -691,17 +788,34 @@ class ClientPatcher {
|
||||
success: true
|
||||
};
|
||||
|
||||
const clientPath = this.findClientPath(gameDir);
|
||||
if (clientPath) {
|
||||
if (progressCallback) progressCallback('Patching client binary...', 10);
|
||||
results.client = await this.patchClient(clientPath, (msg, pct) => {
|
||||
if (progressCallback) {
|
||||
progressCallback(`Client: ${msg}`, pct ? pct / 2 : null);
|
||||
}
|
||||
});
|
||||
const clientPaths = this.findClientBinaries(gameDir);
|
||||
if (clientPaths.length > 0) {
|
||||
if (progressCallback) progressCallback('Patching client binaries...', 10);
|
||||
const clientResults = [];
|
||||
let totalPatchCount = 0;
|
||||
|
||||
for (const clientPath of clientPaths) {
|
||||
const result = await this.patchClient(clientPath, (msg, pct) => {
|
||||
if (progressCallback) {
|
||||
progressCallback(`Client: ${path.basename(clientPath)}: ${msg}`, pct ? pct / 2 : null);
|
||||
}
|
||||
});
|
||||
clientResults.push({ path: clientPath, ...result });
|
||||
totalPatchCount += result.patchCount || 0;
|
||||
}
|
||||
|
||||
const allSuccess = clientResults.every((entry) => entry.success);
|
||||
const allAlreadyPatched = clientResults.every((entry) => entry.alreadyPatched);
|
||||
|
||||
results.client = {
|
||||
success: allSuccess,
|
||||
patchCount: totalPatchCount,
|
||||
alreadyPatched: allAlreadyPatched,
|
||||
binaries: clientResults
|
||||
};
|
||||
} else {
|
||||
console.warn('Could not find HytaleClient binary');
|
||||
results.client = { success: false, error: 'Client binary not found' };
|
||||
console.warn('Could not find HytaleClient binaries');
|
||||
results.client = { success: false, error: 'Client binaries not found' };
|
||||
}
|
||||
|
||||
// Download DualAuth ByteBuddy Agent (runtime patching, no JAR modification)
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"dev": "electron . --dev",
|
||||
"test": "node backend/utils/__tests__/clientPatcher.test.js",
|
||||
"build": "electron-builder",
|
||||
"build:win": "electron-builder --win",
|
||||
"build:linux": "electron-builder --linux",
|
||||
|
||||
Reference in New Issue
Block a user