545 lines
15 KiB
TypeScript
545 lines
15 KiB
TypeScript
import path from 'path';
|
|
|
|
import { screen } from 'electron';
|
|
import fs from 'fs-extra';
|
|
import Logger from 'electron-log/main';
|
|
|
|
import Preferences from '~main/modules/preferences';
|
|
import { ConfigWtfSchema, type PreferencesSchema } from '~common/schemas';
|
|
import { isNotUndef } from '~common/utils';
|
|
import { readPristineWow } from '~main/modules/aria2';
|
|
import { enumerateDisplays } from '~main/modules/displays';
|
|
|
|
const Servers = {
|
|
live: {
|
|
realmList: 'octowow.st',
|
|
patchList: 'octowow.st',
|
|
realmName: 'OctoWoW'
|
|
},
|
|
ptr: {
|
|
realmList: import.meta.env.MAIN_VITE_PTR_REALMLIST || 'octowow.st',
|
|
patchList: import.meta.env.MAIN_VITE_PTR_REALMLIST || 'octowow.st',
|
|
realmName: 'OctoWoW PTR'
|
|
}
|
|
} as const;
|
|
|
|
const LOCALES = {
|
|
enUS: { tag: 'enUS', index: 0 },
|
|
deDE: { tag: 'deDE', index: 3 },
|
|
zhCN: { tag: 'zhCN', index: 4 },
|
|
ruRU: { tag: 'ruRU', index: 5 },
|
|
esES: { tag: 'esES', index: 6 },
|
|
ptBR: { tag: 'ptBR', index: 7 }
|
|
} as const satisfies Record<
|
|
PreferencesSchema['locale'],
|
|
{ tag: string; index: number }
|
|
>;
|
|
|
|
const LOCALE_NAMES = [
|
|
'enUS',
|
|
'koKR',
|
|
'frFR',
|
|
'deDE',
|
|
'zhCN',
|
|
'zhTW',
|
|
'esES',
|
|
'xxYY'
|
|
] as const;
|
|
|
|
const localeNameOffset = (index: number) => 0x45591c - index * 8;
|
|
|
|
const carrierName = (index: number) => LOCALE_NAMES[index];
|
|
|
|
type TweakKey =
|
|
| { synthetic?: false; key: keyof PreferencesSchema['config'] }
|
|
| { synthetic: true; key: string };
|
|
|
|
type Tweak = TweakKey & {
|
|
default?: unknown;
|
|
forced?: boolean;
|
|
} & (
|
|
| {
|
|
type: 'bytes';
|
|
tweaks: [number, number[], number[]?][];
|
|
}
|
|
| {
|
|
type: 'int8' | 'uint16' | 'float';
|
|
offset: number;
|
|
value?: number;
|
|
}
|
|
);
|
|
|
|
const hex = (bytes: number[]) =>
|
|
bytes.map(b => b.toString(16).padStart(2, '0')).join(' ');
|
|
|
|
export const patchExecutable = async () => {
|
|
Logger.log('Patching WoW.exe...');
|
|
|
|
const { clientDir, config, locale } = Preferences.data;
|
|
if (!clientDir) return;
|
|
const exePath = path.join(clientDir, 'WoW.exe');
|
|
|
|
try {
|
|
Logger.log('Reading clean WoW.exe base...');
|
|
const buffer = await readPristineWow(clientDir);
|
|
|
|
const loc = LOCALES[locale];
|
|
|
|
// revert any previous locale patch to the pristine bytes first, so a
|
|
// language switch (or an adopted pre-patched exe) can re-patch cleanly
|
|
const TAG_OFFSET = 0x1b2115;
|
|
const INDEX_OFFSET = 0x253c;
|
|
const PRISTINE_TAG = [0xa1, 0xa4, 0xa2, 0xc2, 0x00];
|
|
const PRISTINE_INDEX = [0x33, 0xf6, 0x8b, 0xff, 0x8b, 0x04, 0xb5];
|
|
if (
|
|
buffer[TAG_OFFSET] === 0xb8 &&
|
|
buffer[INDEX_OFFSET] === 0xbe &&
|
|
buffer[INDEX_OFFSET + 5] === 0xeb
|
|
) {
|
|
const prevIndex = buffer[INDEX_OFFSET + 1];
|
|
const prevCarrier = LOCALE_NAMES[prevIndex] as string | undefined;
|
|
const prevTag = prevCarrier
|
|
? Buffer.from([
|
|
0xb8,
|
|
...Buffer.from(prevCarrier, 'latin1').reverse()
|
|
])
|
|
: undefined;
|
|
if (
|
|
prevCarrier &&
|
|
prevTag &&
|
|
buffer.subarray(TAG_OFFSET, TAG_OFFSET + 5).equals(prevTag)
|
|
) {
|
|
Logger.log(
|
|
`Reverting previous locale patch (index ${prevIndex}) to the clean base`
|
|
);
|
|
Buffer.from(PRISTINE_TAG).copy(buffer, TAG_OFFSET);
|
|
Buffer.from(PRISTINE_INDEX).copy(buffer, INDEX_OFFSET);
|
|
Buffer.from(prevCarrier, 'latin1').copy(
|
|
buffer,
|
|
localeNameOffset(prevIndex)
|
|
);
|
|
}
|
|
}
|
|
|
|
const Tweaks = [
|
|
{
|
|
key: 'largeAddress',
|
|
type: 'uint16',
|
|
offset: 0x126,
|
|
value: buffer.readUint16LE(0x126) | 0x20,
|
|
default: false
|
|
},
|
|
{ key: 'farClip', type: 'float', offset: 0x40fed8 },
|
|
{
|
|
key: 'fieldOfView',
|
|
type: 'float',
|
|
offset: 0x4089b4,
|
|
value: (config.fieldOfView ?? 1) * (Math.PI / 180),
|
|
default: 90
|
|
},
|
|
{ key: 'frillDistance', type: 'float', offset: 0x467958 },
|
|
{
|
|
key: 'soundInBackground',
|
|
type: 'int8',
|
|
offset: 0x3a4869,
|
|
value: config.soundInBackground ? 0x27 : 0x14,
|
|
default: false
|
|
},
|
|
{
|
|
// shipped exe carries the enabled bytes; off must write 0x74 back
|
|
key: 'alwaysAutoLoot',
|
|
type: 'bytes',
|
|
tweaks: [
|
|
[0x0c1ecf, [0x75], [0x74]],
|
|
[0x0c2b25, [0x75], [0x74]]
|
|
]
|
|
},
|
|
{ key: 'nameplateRange', type: 'float', offset: 0x40c448 },
|
|
{ key: 'cameraDistance', type: 'float', offset: 0x4089a4 },
|
|
{
|
|
synthetic: true,
|
|
key: 'skillUiGateHijack',
|
|
type: 'bytes',
|
|
default: true,
|
|
forced: true,
|
|
tweaks: [
|
|
[
|
|
0x002ddf90,
|
|
[
|
|
0x55, 0x8b, 0xec, 0x83, 0xec, 0x08, 0x53, 0x56, 0x57, 0x8b, 0x3d,
|
|
0x60, 0xab, 0xce, 0x00, 0x83, 0xff, 0xff, 0x89, 0x55, 0xfc, 0x89,
|
|
0x4d, 0xf8, 0x74, 0x79, 0x8b, 0x75, 0x08, 0x8b, 0x15, 0x58, 0xab,
|
|
0xce, 0x00, 0x8b, 0xc7, 0x23, 0xc6, 0x8d, 0x04, 0x40, 0x8b, 0x4c,
|
|
0x82, 0x08, 0xf6, 0xc1, 0x01, 0x8d, 0x44, 0x82, 0x04, 0x75, 0x04,
|
|
0x85, 0xc9, 0x75, 0x05, 0x33, 0xc9, 0x8d, 0x49, 0x00, 0xf6, 0xc1,
|
|
0x01, 0x75, 0x4e, 0x85, 0xc9, 0x74, 0x4a, 0x39, 0x31, 0x74, 0x13,
|
|
0x8b, 0xc7, 0x23, 0xc6, 0x8d, 0x04, 0x40, 0x8d, 0x04, 0x82, 0x8b,
|
|
0x00, 0x03, 0xc1, 0x8b, 0x48, 0x04, 0xeb, 0xe0, 0x8b, 0x59, 0x1c,
|
|
0x8b, 0x71, 0x18, 0x33, 0xff, 0x85, 0xdb, 0x7e, 0x27, 0x8d, 0x64,
|
|
0x24, 0x00, 0x8b, 0x4e, 0x0c, 0x8b, 0x56, 0x08, 0x6a, 0x00, 0x6a,
|
|
0x00, 0x51, 0x8b, 0x4d, 0xf8, 0x52, 0x8b, 0x55, 0xfc, 0xe8, 0xb9,
|
|
0xfd, 0xff, 0xff, 0x84, 0xc0, 0x75, 0x13, 0x47, 0x83, 0xc6, 0x20,
|
|
0x3b, 0xfb, 0x7c, 0xdd, 0x5f, 0x5e, 0x33, 0xc0, 0x5b, 0x8b, 0xe5,
|
|
0x5d, 0xc2, 0x04, 0x00, 0x5f, 0x8b, 0xc6, 0x5e, 0x5b, 0x8b, 0xe5,
|
|
0x5d, 0xc2, 0x04, 0x00, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90
|
|
]
|
|
]
|
|
]
|
|
},
|
|
{
|
|
synthetic: true,
|
|
key: 'octowowUrlAllowlist',
|
|
type: 'bytes',
|
|
default: true,
|
|
forced: true,
|
|
tweaks: [
|
|
[
|
|
0x45ccd8,
|
|
[
|
|
0x6f, 0x63, 0x74, 0x6f, 0x77, 0x6f, 0x77, 0x2e, 0x73, 0x74, 0x00,
|
|
0x00, 0x00, 0x00, 0x00, 0x00
|
|
]
|
|
]
|
|
]
|
|
},
|
|
{
|
|
synthetic: true,
|
|
key: 'localeTag',
|
|
type: 'bytes',
|
|
default: true,
|
|
forced: true,
|
|
tweaks: [
|
|
[
|
|
0x1b2115,
|
|
[0xb8, ...Buffer.from(carrierName(loc.index), 'latin1').reverse()],
|
|
[0xa1, 0xa4, 0xa2, 0xc2, 0x00]
|
|
]
|
|
]
|
|
},
|
|
{
|
|
synthetic: true,
|
|
key: 'localeIndex',
|
|
type: 'bytes',
|
|
default: true,
|
|
forced: true,
|
|
tweaks: [
|
|
[
|
|
0x253c,
|
|
[0xbe, loc.index, 0x00, 0x00, 0x00, 0xeb, 0x1f],
|
|
[0x33, 0xf6, 0x8b, 0xff, 0x8b, 0x04, 0xb5]
|
|
]
|
|
]
|
|
},
|
|
{
|
|
synthetic: true,
|
|
key: 'localeName',
|
|
type: 'bytes',
|
|
default: true,
|
|
forced: true,
|
|
tweaks: [
|
|
[
|
|
localeNameOffset(loc.index),
|
|
[...Buffer.from(loc.tag, 'latin1')],
|
|
[...Buffer.from(LOCALE_NAMES[loc.index], 'latin1')]
|
|
]
|
|
]
|
|
}
|
|
] satisfies Tweak[];
|
|
|
|
Tweaks.forEach(t => {
|
|
const val = t.synthetic
|
|
? t.default
|
|
: config[t.key] ?? t.default ?? ConfigWtfSchema.parse({})[t.key];
|
|
|
|
Logger.log(`Applying "${t.key}" patch with value: ${val}`);
|
|
if (t.type === 'float') {
|
|
buffer.writeFloatLE(t.value ?? (val as number), t.offset);
|
|
} else if (t.type === 'int8') {
|
|
buffer.writeInt8(t.value ?? (val as number), t.offset);
|
|
} else if (t.type === 'uint16') {
|
|
if (!t.forced && !val) return;
|
|
buffer.writeUInt16LE(t.value ?? (val as number), t.offset);
|
|
} else if (t.type === 'bytes') {
|
|
if (!t.forced && !val) {
|
|
// disabled: revert sites carrying the enabled bytes to the
|
|
// stock bytes when known; unknown bytes stay untouched
|
|
t.tweaks.forEach(
|
|
([offset, bytes, expect]: [number, number[], number[]?]) => {
|
|
if (!expect) return;
|
|
const current = buffer.subarray(
|
|
offset,
|
|
offset + bytes.length
|
|
);
|
|
if (current.equals(Buffer.from(bytes)))
|
|
Buffer.from(expect).copy(buffer, offset);
|
|
}
|
|
);
|
|
return;
|
|
}
|
|
t.tweaks.forEach(
|
|
([offset, bytes, expect]: [number, number[], number[]?]) => {
|
|
if (expect) {
|
|
const current = buffer.subarray(offset, offset + expect.length);
|
|
if (current.equals(Buffer.from(bytes))) return;
|
|
if (!current.equals(Buffer.from(expect)))
|
|
throw new Error(
|
|
`"${t.key}" expected [${hex(expect)}] at 0x${offset.toString(
|
|
16
|
|
)} ` +
|
|
`but found [${hex([
|
|
...current
|
|
])}]; refusing to patch WoW.exe`
|
|
);
|
|
}
|
|
const written = Buffer.from(bytes).copy(buffer, offset);
|
|
if (written !== bytes.length)
|
|
Logger.error(
|
|
`"${t.key}" wrote ${written}/${bytes.length} bytes at ` +
|
|
`0x${offset.toString(16)}: past end of file (${
|
|
buffer.length
|
|
} bytes). ` +
|
|
'This tweak is a no-op; the offset is probably a virtual address.'
|
|
);
|
|
}
|
|
);
|
|
}
|
|
});
|
|
|
|
await fs.writeFile(exePath, buffer);
|
|
Preferences.data = { patchedLocale: locale };
|
|
Logger.log(`WoW.exe successfully patched (language: ${locale})`);
|
|
} catch (e) {
|
|
Logger.error('Failed to patch WoW.exe', e);
|
|
throw e instanceof Error ? e : new Error('Failed to patch WoW.exe');
|
|
}
|
|
};
|
|
|
|
const repairResolution = async (
|
|
clientDir: string,
|
|
current: string | undefined,
|
|
lastWritten: string | undefined
|
|
): Promise<{ gxResolution?: string }> => {
|
|
const devices = await enumerateDisplays();
|
|
if (!devices?.length) return {};
|
|
|
|
const pinnedRaw = await fs
|
|
.readFile(path.join(clientDir, 'VMMFix_preferred_monitor.txt'), 'utf8')
|
|
.then(s => s.trim())
|
|
.catch(() => '');
|
|
const pinned = pinnedRaw ? Number(pinnedRaw) : NaN;
|
|
const target =
|
|
devices.find(d => d.index === pinned && d.attached) ??
|
|
devices.find(d => d.primary && d.attached);
|
|
if (!target?.modes.length) return {};
|
|
|
|
const native = `${target.width}x${target.height}`;
|
|
if (!target.modes.includes(native)) return {};
|
|
|
|
let owned = !current || current === lastWritten;
|
|
|
|
if (!owned && lastWritten === undefined && current) {
|
|
const width = Number(current.split('x')[0]);
|
|
if (Number.isFinite(width) && width * 2 < target.width) {
|
|
Logger.warn(
|
|
`gxResolution ${current} is far below ${target.deviceName}'s ${native} and predates resolution tracking; treating it as a client fallback`
|
|
);
|
|
owned = true;
|
|
}
|
|
}
|
|
|
|
if (!owned) return {};
|
|
if (current === native) return {};
|
|
|
|
Logger.warn(
|
|
`gxResolution ${current ?? '<unset>'} is launcher-owned; correcting to ${
|
|
target.deviceName
|
|
}'s ${native}`
|
|
);
|
|
return { gxResolution: native };
|
|
};
|
|
|
|
const applyRealmlist = async (clientDir: string, host: string) => {
|
|
const body = `set realmlist "${host}"\n`;
|
|
const write = async (target: string) => {
|
|
// already correct: leave it alone (the seeder may hold the file open)
|
|
const current = await fs
|
|
.readFile(target, { encoding: 'utf-8' })
|
|
.catch(() => null);
|
|
if (current === body) return;
|
|
const tmp = `${target}.tmp`;
|
|
try {
|
|
await fs.writeFile(tmp, body);
|
|
await fs.move(tmp, target, { overwrite: true });
|
|
} catch (e) {
|
|
await fs.remove(tmp).catch(() => undefined);
|
|
throw e;
|
|
}
|
|
};
|
|
await write(path.join(clientDir, 'realmlist.wtf'));
|
|
const dataDir = path.join(clientDir, 'Data');
|
|
if (await fs.pathExists(dataDir))
|
|
for (const entry of await fs.readdir(dataDir)) {
|
|
const scoped = path.join(dataDir, entry, 'realmlist.wtf');
|
|
if (!(await fs.pathExists(scoped))) continue;
|
|
try {
|
|
await write(scoped);
|
|
} catch (e) {
|
|
Logger.warn(`Could not rewrite ${scoped}: ${String(e)}`);
|
|
}
|
|
}
|
|
};
|
|
|
|
// rewrite realmlist.wtf when missing, empty, or wrong; an interrupted sync
|
|
// can leave a 0-byte placeholder that disconnects direct game launches
|
|
export const healRealmlist = async (clientDir: string) => {
|
|
const server: keyof typeof Servers = import.meta.env.MAIN_VITE_PTR_REALMLIST
|
|
? 'ptr'
|
|
: 'live';
|
|
const expected = `set realmlist "${Servers[server].realmList}"\n`;
|
|
const target = path.join(clientDir, 'realmlist.wtf');
|
|
const current = await fs
|
|
.readFile(target, { encoding: 'utf-8' })
|
|
.catch(() => null);
|
|
if (current === expected) return;
|
|
Logger.log(
|
|
`realmlist.wtf ${
|
|
current === null ? 'missing' : current.trim() ? 'wrong' : 'empty'
|
|
}; rewriting`
|
|
);
|
|
await applyRealmlist(clientDir, Servers[server].realmList);
|
|
};
|
|
|
|
export const patchConfig = async (forceTweaks = false) => {
|
|
const { clientDir, config, locale } = Preferences.data;
|
|
if (!clientDir) return;
|
|
|
|
const server: keyof typeof Servers = import.meta.env.MAIN_VITE_PTR_REALMLIST
|
|
? 'ptr'
|
|
: 'live';
|
|
|
|
const configPath = path.join(clientDir, 'WTF', 'Config.wtf');
|
|
await fs.ensureDir(path.dirname(configPath));
|
|
const raw = (await fs.pathExists(configPath))
|
|
? await fs.readFile(configPath, { encoding: 'utf-8' })
|
|
: '';
|
|
|
|
const configWtf = Object.fromEntries(
|
|
raw
|
|
.split(/\r?\n/)
|
|
.map(l => {
|
|
const [, k, v] = l.match(/SET (\w+) "(.*)"/) ?? [];
|
|
return !k || v === undefined ? undefined : [k, v];
|
|
})
|
|
.filter(isNotUndef)
|
|
);
|
|
|
|
const isFirstRun = Object.keys(configWtf).length === 0;
|
|
|
|
const primaryDisplay = screen.getPrimaryDisplay();
|
|
const scale = primaryDisplay.scaleFactor || 1;
|
|
const width = Math.round(primaryDisplay.bounds.width * scale);
|
|
const height = Math.round(primaryDisplay.bounds.height * scale);
|
|
|
|
const seededResolution = `${width}x${height}`;
|
|
|
|
const seed = isFirstRun
|
|
? {
|
|
scriptMemory: 512000,
|
|
gxResolution: seededResolution,
|
|
gxColorBits: primaryDisplay.colorDepth,
|
|
gxDepthBits: primaryDisplay.colorDepth,
|
|
gxRefresh: 60,
|
|
gxMultisample: 8,
|
|
gxMultisampleQuality: 0,
|
|
gxTripleBuffer: 1,
|
|
anisotropic: 16,
|
|
frillDensity: 48,
|
|
fullAlpha: 1,
|
|
SmallCull: 0.01,
|
|
DistCull: 888.8,
|
|
shadowLevel: 0,
|
|
trilinear: 1,
|
|
specular: 1,
|
|
pixelShaders: 1,
|
|
M2UsePixelShaders: 1,
|
|
M2UseShaders: 1,
|
|
particleDensity: 1,
|
|
unitDrawDist: 300,
|
|
weatherDensity: 3,
|
|
movieSubtitle: 1,
|
|
minimapZoom: 0,
|
|
minimapInsideZoom: 0,
|
|
SoundZoneMusicNoDelay: 1,
|
|
gxWindow: 1,
|
|
gxMaximize: 1,
|
|
gxCursor: 1,
|
|
checkAddonVersion: 0,
|
|
farClip: config.farClip,
|
|
CameraDistanceMax: config.cameraDistance,
|
|
patchList: Servers[server].patchList,
|
|
realmName: Servers[server].realmName
|
|
}
|
|
: {};
|
|
|
|
const owned = {
|
|
locale: carrierName(LOCALES[locale].index),
|
|
patchList: configWtf['patchList'] ?? Servers[server].patchList,
|
|
realmName: configWtf['realmName'] ?? Servers[server].realmName,
|
|
hwDetect: 0,
|
|
BackgroundSound: config.soundInBackground ? 1 : 0
|
|
};
|
|
|
|
const repaired = await repairResolution(
|
|
clientDir,
|
|
configWtf['gxResolution'],
|
|
Preferences.data.lastWrittenResolution
|
|
);
|
|
|
|
const parsed = {
|
|
...seed,
|
|
...configWtf,
|
|
...repaired,
|
|
...owned,
|
|
...(forceTweaks
|
|
? { farClip: config.farClip, CameraDistanceMax: config.cameraDistance }
|
|
: {})
|
|
};
|
|
|
|
const body = Object.entries(parsed)
|
|
.filter(v => v[1] !== undefined && v[1] !== null)
|
|
.filter(([k]) => !/^realmlist$/i.test(k))
|
|
.map(l => `SET ${l[0]} "${l[1]}"`)
|
|
.join('\n');
|
|
const tmpPath = `${configPath}.tmp`;
|
|
await fs.writeFile(tmpPath, body);
|
|
await fs.move(tmpPath, configPath, { overwrite: true });
|
|
|
|
await applyRealmlist(clientDir, Servers[server].realmList);
|
|
|
|
const chosen =
|
|
repaired.gxResolution ?? (isFirstRun ? seededResolution : undefined);
|
|
if (chosen && chosen !== Preferences.data.lastWrittenResolution)
|
|
Preferences.data = { lastWrittenResolution: chosen };
|
|
|
|
Logger.log('Config.wtf successfully patched');
|
|
};
|
|
|
|
export const ensureDxvkConf = async (clientDir: string) => {
|
|
if (!(await fs.pathExists(path.join(clientDir, 'd3d9.dll')))) return;
|
|
const confPath = path.join(clientDir, 'dxvk.conf');
|
|
if (await fs.pathExists(confPath)) return;
|
|
await fs.writeFile(
|
|
confPath,
|
|
[
|
|
'# Cap the texture memory the 32-bit client believes it has so it cannot',
|
|
'# over-commit its address space (the common DXVK out-of-memory crash).',
|
|
'd3d9.maxAvailableMemory = 2048',
|
|
'd3d9.maxFrameLatency = 1',
|
|
'dxvk.numCompilerThreads = 2',
|
|
'dxvk.logLevel = none',
|
|
''
|
|
].join('\n')
|
|
);
|
|
Logger.log('Wrote dxvk.conf');
|
|
};
|