MAESTRO: harden patcher replacements for multi-binary installs

This commit is contained in:
sanasol
2026-03-05 18:43:33 +01:00
parent c5e5ddbcc4
commit 0e50ded8e4
3 changed files with 178 additions and 27 deletions

View 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');

View File

@@ -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)

View File

@@ -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",