Sync launcher 1.2.2: monitor-aware resolution ownership, SuperWoW mod and mods-on-by-default, Patch-O raid visuals toggle, background-sound CVar, launch/quit stability fixes
Build check / build (push) Has been cancelled

This commit is contained in:
OctoWoW
2026-08-05 17:20:48 -07:00
parent 5812065b56
commit 1255e40d01
19 changed files with 767 additions and 131 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "octo-launcher",
"version": "1.2.1",
"version": "1.2.2",
"description": "An Electron application for launching and updating the OctoWoW client",
"author": "OctoWoW",
"copyright": "Copyright © 2026 OctoWoW",
+97
View File
@@ -0,0 +1,97 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const FILL = 0x78;
function pathVariants(p) {
const set = new Set([p, p.replace(/\\/g, '/'), p.replace(/\//g, '\\')]);
return [...set].filter(Boolean);
}
const root = process.cwd();
const home = os.homedir();
let username = '';
try {
username = os.userInfo().username;
} catch {
username = '';
}
const secrets = [];
if (root.length > 2) secrets.push(...pathVariants(root));
if (home.length > 2 && home !== root) secrets.push(...pathVariants(home));
if (username.length >= 4) secrets.push(username);
const needles = [...new Set(secrets)]
.filter(s => s.length > 0)
.map(s => s.toLowerCase())
.sort((a, b) => b.length - a.length);
function scanAndFill(buf, s, stride) {
const n = s.length;
const span = n * stride;
if (span === 0 || span > buf.length) return 0;
let hits = 0;
outer: for (let i = 0; i + span <= buf.length; i++) {
for (let j = 0; j < n; j++) {
const at = i + j * stride;
let b = buf[at];
if (b >= 0x41 && b <= 0x5a) b += 0x20;
if (b !== s.charCodeAt(j)) continue outer;
if (stride === 2 && buf[at + 1] !== 0x00) continue outer;
}
buf.fill(FILL, i, i + span);
hits++;
i += span - 1;
}
return hits;
}
function redact(buf) {
let hits = 0;
for (const s of needles) {
hits += scanAndFill(buf, s, 1);
hits += scanAndFill(buf, s, 2);
}
return hits;
}
function collect(dir, out) {
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
const p = path.join(dir, e.name);
if (e.isDirectory()) collect(p, out);
else if (e.isFile() && p.endsWith('.node')) out.push(p);
}
}
const addons = [];
collect(path.join(root, 'node_modules'), addons);
let files = 0;
let total = 0;
const redacted = [];
for (const file of addons) {
try {
const buf = fs.readFileSync(file);
const hits = redact(buf);
if (hits > 0) {
fs.writeFileSync(file, buf);
files++;
total += hits;
redacted.push(`${path.relative(root, file)} (${hits})`);
}
} catch (err) {
console.warn(`scrub-native-paths: skipped ${path.basename(file)} (${err.message})`);
}
}
console.log(`scrub-native-paths: redacted ${total} path reference(s) across ${files} addon(s)`);
for (const r of redacted) console.log(` ${r}`);
+9 -1
View File
@@ -15,6 +15,7 @@ const allowedExtra = [
];
const vanillaFixes = ['VfPatcher.dll', 'd3d9.dll', 'dxvk.conf'];
const raidVisuals = ['patch-O.mpq'];
const skipFiles = new Set([
'manifest.json',
@@ -45,7 +46,7 @@ const isSkipDir = (...filePath: string[]) =>
skipDirsPosix.has(filePath.join('/'));
type FolderTags = 'allowExtra';
type FileTags = 'vanillaFixes';
type FileTags = 'vanillaFixes' | 'raidVisuals';
type FileManifest = { name: string } & (
| { type: 'dir'; files: FileManifest[]; tags?: FolderTags[] }
@@ -191,6 +192,12 @@ export const buildCache = async (
if (stats.isDirectory()) {
if (isSkipDir(...filePath, file)) continue;
if (file.match(/patch-./)) {
if (raidVisuals.includes(`${file}.mpq`))
throw new Error(
`${file}/ exists beside ${file}.mpq. Opt-in archives must stay ` +
'whole-file: an mpq node carries no tags, so this would ' +
'ship the patch to every player regardless of preference.'
);
patches.push(file);
const mpqRelPath = path
.join(...filePath, `${file}.mpq`)
@@ -243,6 +250,7 @@ export const buildCache = async (
const tags: FileTags[] = [];
vanillaFixes.includes(file) && tags.push('vanillaFixes');
raidVisuals.includes(file) && tags.push('raidVisuals');
tree.push({
type: 'file',
+31
View File
@@ -4,6 +4,7 @@ export const ModIdSchema = z.enum([
'dxvk',
'nampower',
'multiMonitorFix',
'superWow',
'transmogFix',
'unitXp',
'vanillaFixes',
@@ -19,6 +20,7 @@ export type ModSource =
apiUrl?: string;
pinnedTag?: string;
assetName: string;
sha256?: string;
}
| {
kind: 'archive';
@@ -28,6 +30,7 @@ export type ModSource =
pinnedTag?: string;
format: 'zip' | 'tar.gz';
extractMap: Record<string, string>;
sha256?: string;
}
| { kind: 'managed' };
@@ -98,6 +101,28 @@ export const MODS: ModEntry[] = [
},
registerInDllsTxt: 'VanillaMultiMonitorFix.dll'
},
{
id: 'superWow',
name: 'SuperWoW',
version: '2.2',
description:
'Extends the client Lua API with unit GUIDs and other data many addons rely on.',
repoUrl: 'https://github.com/balakethelock/SuperWoW',
requires: ['vanillaFixes'],
source: {
kind: 'archive',
url: 'https://github.com/balakethelock/SuperWoW/releases/download/Release/SuperWoW.release.2.2.zip',
apiUrl:
'https://api.github.com/repos/balakethelock/SuperWoW/releases/latest',
parseLatest: 'githubRelease',
pinnedTag: '2.2',
format: 'zip',
extractMap: {
'SuperWoWhook.dll': 'SuperWoWhook.dll'
}
},
registerInDllsTxt: 'SuperWoWhook.dll'
},
{
id: 'transmogFix',
name: 'transmogFix',
@@ -177,3 +202,9 @@ export const MODS: ModEntry[] = [
export const getMod = (id: ModId): ModEntry | undefined =>
MODS.find(m => m.id === id);
const NOT_DEFAULT_ENABLED: ModId[] = [];
export const DEFAULT_ENABLED_MODS: ModId[] = MODS.filter(
m => !NOT_DEFAULT_ENABLED.includes(m.id)
).map(m => m.id);
+3
View File
@@ -17,6 +17,7 @@ const f = {
export const ConfigWtfSchema = z.object({
vanillaFixes: f.boolean(),
raidVisuals: f.boolean(),
largeAddress: f.boolean(true),
nameplateRange: f.number(41),
alwaysAutoLoot: f.boolean(),
@@ -62,6 +63,8 @@ export const PreferencesSchema = z.object({
.default('enUS'),
localePatchLetter: z.string().optional(),
localePatchLocale: z.string().optional(),
vmmfWrittenIndex: z.number().int().nonnegative().optional(),
lastWrittenResolution: z.string().optional(),
rememberPosition: f.boolean(),
windowPosition: z
.object({
+5 -6
View File
@@ -47,6 +47,9 @@ export const launcherRouter = createTRPCRouter({
await fs.remove(path.join(clientDir, 'WDB'));
}
Logger.log('Syncing preferred monitor...');
await Mods.verify();
Logger.log('Checking Config.wtf...');
await patchConfig();
@@ -62,21 +65,17 @@ export const launcherRouter = createTRPCRouter({
'launching WoW.exe directly (mods will not load).'
);
const octoLocale = Preferences.data.locale || 'enUS';
const gameEnv = { ...process.env, OCTO_LOCALE: octoLocale };
Logger.log(
useLoader
? `Launching via VanillaFixes (OCTO_LOCALE=${octoLocale})...`
: `Launching ${exePath} (OCTO_LOCALE=${octoLocale})...`
? 'Launching via VanillaFixes...'
: `Launching ${exePath}...`
);
const child = useLoader
? spawn(loaderPath, ['WoW.exe'], {
env: gameEnv,
cwd: clientDir,
detached: !minimizeToTrayOnPlay
})
: spawn(exePath, {
env: gameEnv,
cwd: clientDir,
detached: !minimizeToTrayOnPlay
});
+11
View File
@@ -21,6 +21,7 @@ import {
Logger.initialize();
Logger.errorHandler.startCatching();
Logger.transports.ipc.level = false;
Logger.info('Launcher starting...');
app.disableHardwareAcceleration();
@@ -191,6 +192,16 @@ if (!gotSingleInstanceLock) {
await createWindow();
});
let settingsFlushed = false;
app.on('before-quit', event => {
if (settingsFlushed) return;
settingsFlushed = true;
event.preventDefault();
Promise.race([Preferences.save(), new Promise(r => setTimeout(r, 3000))])
.catch(e => Logger.error('Failed to flush settings before quit', e))
.finally(() => app.quit());
});
app.on('window-all-closed', () => {
app.quit();
});
+1 -1
View File
@@ -195,7 +195,7 @@ class AddonsClass extends Observable<AddonsStatus> {
: [];
const addons: AddonsStatus['addons'] = Object.fromEntries(
dirs
.filter(d => !d.startsWith('Blizzard_'))
.filter(d => !d.startsWith('Blizzard_') && !/\.(tmp|bak)$/.test(d))
.map(name => [name, { status: 'fetching' as const, folder: name }])
);
+88 -18
View File
@@ -3,8 +3,21 @@ import { spawn } from 'node:child_process';
import Logger from 'electron-log/main';
export type DisplayDevice = {
index: number;
deviceName: string;
deviceString: string;
attached: boolean;
primary: boolean;
width: number;
height: number;
refresh: number;
modes: string[];
};
const SCRIPT = [
'$ErrorActionPreference = "Stop"',
"$ProgressPreference = 'SilentlyContinue'",
"Add-Type -TypeDefinition @'",
'using System;',
'using System.Runtime.InteropServices;',
@@ -18,30 +31,79 @@ const SCRIPT = [
' [MarshalAs(UnmanagedType.ByValTStr, SizeConst=128)] public string DeviceID;',
' [MarshalAs(UnmanagedType.ByValTStr, SizeConst=128)] public string DeviceKey;',
' }',
' [StructLayout(LayoutKind.Sequential, CharSet=CharSet.Ansi)]',
' public struct DEVMODE {',
' [MarshalAs(UnmanagedType.ByValTStr, SizeConst=32)] public string dmDeviceName;',
' public short dmSpecVersion; public short dmDriverVersion; public short dmSize; public short dmDriverExtra;',
' public int dmFields; public int dmPositionX; public int dmPositionY;',
' public int dmDisplayOrientation; public int dmDisplayFixedOutput;',
' public short dmColor; public short dmDuplex; public short dmYResolution; public short dmTTOption; public short dmCollate;',
' [MarshalAs(UnmanagedType.ByValTStr, SizeConst=32)] public string dmFormName;',
' public short dmLogPixels; public int dmBitsPerPel; public int dmPelsWidth; public int dmPelsHeight;',
' public int dmDisplayFlags; public int dmDisplayFrequency;',
' public int dmICMMethod; public int dmICMIntent; public int dmMediaType; public int dmDitherType;',
' public int dmReserved1; public int dmReserved2; public int dmPanningWidth; public int dmPanningHeight;',
' }',
' [DllImport("user32.dll", EntryPoint="EnumDisplayDevicesA", CharSet=CharSet.Ansi)]',
' public static extern bool EnumDisplayDevices(string lpDevice, uint iDevNum, ref DISPLAY_DEVICE lpDisplayDevice, uint dwFlags);',
' [DllImport("user32.dll", EntryPoint="EnumDisplaySettingsA", CharSet=CharSet.Ansi)]',
' public static extern bool EnumDisplaySettings(string lpszDeviceName, int iModeNum, ref DEVMODE lpDevMode);',
'}',
"'@",
'for ($i = 0; ; $i++) {',
' $dd = New-Object VmmfDisplays+DISPLAY_DEVICE',
' $dd.cb = [System.Runtime.InteropServices.Marshal]::SizeOf($dd)',
'for ($i = 0; [VmmfDisplays]::EnumDisplayDevices([NullString]::Value, $i, [ref]$dd, 0); $i++) {',
' if ($dd.StateFlags -band 4) { Write-Output $i; exit 0 }',
' if (-not [VmmfDisplays]::EnumDisplayDevices([NullString]::Value, $i, [ref]$dd, 0)) { break }',
' $dm = New-Object VmmfDisplays+DEVMODE',
' $dm.dmSize = [System.Runtime.InteropServices.Marshal]::SizeOf($dm)',
' $cur = ""',
' if ([VmmfDisplays]::EnumDisplaySettings($dd.DeviceName, -1, [ref]$dm)) {',
' $cur = "$($dm.dmPelsWidth)|$($dm.dmPelsHeight)|$($dm.dmDisplayFrequency)"',
' }',
'exit 1'
' $modes = New-Object System.Collections.Generic.HashSet[string]',
' for ($m = 0; ; $m++) {',
' $d2 = New-Object VmmfDisplays+DEVMODE',
' $d2.dmSize = [System.Runtime.InteropServices.Marshal]::SizeOf($d2)',
' if (-not [VmmfDisplays]::EnumDisplaySettings($dd.DeviceName, $m, [ref]$d2)) { break }',
' [void]$modes.Add("$($d2.dmPelsWidth)x$($d2.dmPelsHeight)")',
' }',
' Write-Output ("{0}`t{1}`t{2}`t{3}`t{4}`t{5}" -f $i, $dd.DeviceName, $dd.DeviceString, $dd.StateFlags, $cur, ($modes -join ","))',
'}'
].join('\n');
export const detectPrimaryDisplayIndex = (): Promise<number> => {
if (os.platform() !== 'win32') return Promise.resolve(0);
const parseRow = (line: string): DisplayDevice | undefined => {
const f = line.split('\t');
if (f.length < 6) return undefined;
const index = Number(f[0]);
const stateFlags = Number(f[3]);
if (!Number.isInteger(index) || index < 0 || !Number.isInteger(stateFlags))
return undefined;
const [w, h, hz] = (f[4] || '').split('|').map(Number);
return {
index,
deviceName: f[1],
deviceString: f[2],
attached: (stateFlags & 1) !== 0,
primary: (stateFlags & 4) !== 0,
width: Number.isFinite(w) ? w : 0,
height: Number.isFinite(h) ? h : 0,
refresh: Number.isFinite(hz) ? hz : 0,
modes: (f[5] || '').split(',').filter(Boolean)
};
};
export const enumerateDisplays = (): Promise<DisplayDevice[] | null> => {
if (os.platform() !== 'win32') return Promise.resolve(null);
const encoded = Buffer.from(SCRIPT, 'utf16le').toString('base64');
return new Promise(resolve => {
let settled = false;
const finish = (index: number) => {
const finish = (v: DisplayDevice[] | null) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(index);
resolve(v);
};
const child = spawn(
@@ -52,25 +114,33 @@ export const detectPrimaryDisplayIndex = (): Promise<number> => {
const timer = setTimeout(() => {
child.kill();
Logger.warn('Primary display detection timed out');
finish(0);
}, 8000);
Logger.warn('Display enumeration timed out');
finish(null);
}, 10000);
let stdout = '';
child.stdout.on('data', d => (stdout += String(d)));
child.on('error', e => {
Logger.warn('Primary display detection failed to launch PowerShell', e);
finish(0);
Logger.warn('Display enumeration failed to launch PowerShell', e);
finish(null);
});
child.on('exit', code => {
const index = Number(stdout.trim());
if (code === 0 && Number.isInteger(index) && index >= 0) {
Logger.info(`Detected primary display at device index ${index}`);
finish(index);
const devices = stdout
.split(/\r?\n/)
.map(parseRow)
.filter((d): d is DisplayDevice => d !== undefined);
if (code === 0 && devices.length) {
Logger.info(`Enumerated ${devices.length} display device(s)`);
finish(devices);
} else {
Logger.warn('Primary display detection failed, defaulting to 0');
finish(0);
Logger.warn('Display enumeration returned nothing usable');
finish(null);
}
});
});
};
export const detectPrimaryDisplayIndex = async (): Promise<number | null> => {
const devices = await enumerateDisplays();
return devices?.find(d => d.primary && d.attached)?.index ?? null;
};
+9 -5
View File
@@ -12,7 +12,9 @@ import Logger from 'electron-log/main';
import Preferences from './preferences';
const PREFERRED = 'L';
const LETTERS = 'BCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
const ALL_LETTERS = 'BCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
const RAID_LETTER = 'O';
const ALLOC_LETTERS = ALL_LETTERS.filter(l => l !== RAID_LETTER);
const MARKER = 'octolocale.marker';
const patchFile = (dataDir: string, letter: string) =>
@@ -42,7 +44,7 @@ const usableSlot = (dataDir: string, letter: string): boolean => {
};
const removeOurPatch = async (dataDir: string) => {
for (const l of LETTERS) {
for (const l of ALL_LETTERS) {
const f = patchFile(dataDir, l);
if (isOurPatch(f)) await fs.remove(f).catch(() => {});
}
@@ -79,10 +81,12 @@ export const applyLocalePatch = async (
const tracked = Preferences.data.localePatchLetter;
const letter =
(tracked && usableSlot(dataDir, tracked) ? tracked : undefined) ??
(tracked && tracked !== RAID_LETTER && usableSlot(dataDir, tracked)
? tracked
: undefined) ??
(usableSlot(dataDir, PREFERRED)
? PREFERRED
: LETTERS.find(l => usableSlot(dataDir, l)));
: ALLOC_LETTERS.find(l => usableSlot(dataDir, l)));
if (!letter) {
Logger.warn('Locale patch: no usable patch slot');
return;
@@ -100,7 +104,7 @@ export const applyLocalePatch = async (
}
try {
for (const l of LETTERS) {
for (const l of ALL_LETTERS) {
if (l === letter) continue;
const f = patchFile(dataDir, l);
if (isOurPatch(f)) await fs.remove(f).catch(() => {});
+78 -10
View File
@@ -1,4 +1,5 @@
import path from 'path';
import { createHash } from 'crypto';
import fs from 'fs-extra';
import fetch from 'node-fetch';
@@ -13,7 +14,7 @@ import Preferences from './preferences';
import Observable from './observable';
import Updater from './updater';
import { addDll, removeDll } from './dllsTxt';
import { detectPrimaryDisplayIndex } from './displays';
import { enumerateDisplays } from './displays';
const MOD_DOWNLOAD_TIMEOUT_MS = 60_000;
@@ -105,6 +106,69 @@ class ModsClass extends Observable<ModsStatus> {
};
}
async #syncPreferredMonitor(clientDir: string) {
const vmmfDll = path.join(clientDir, 'VanillaMultiMonitorFix.dll');
if (!(await fs.pathExists(vmmfDll))) return;
const vmmfCfg = path.join(clientDir, 'VMMFix_preferred_monitor.txt');
const existsCfg = await fs.pathExists(vmmfCfg);
const current = existsCfg
? Number(
await fs
.readFile(vmmfCfg, 'utf8')
.then(s => s.trim())
.catch(() => '')
)
: NaN;
const hasCurrent = Number.isInteger(current);
const ours = Preferences.data?.vmmfWrittenIndex;
const devices = await enumerateDisplays();
const usable = devices?.filter(d => d.attached && d.width > 0);
if (!devices || !usable?.length) {
Logger.warn('Could not enumerate displays; preferred monitor unchanged');
return;
}
const primary = usable.find(d => d.primary) ?? usable[0];
if (hasCurrent && ours === undefined) {
const pinned = devices.find(d => d.index === current);
const broken = !pinned || !pinned.attached || !pinned.primary;
if (!broken) {
Preferences.data = { vmmfWrittenIndex: current };
Logger.info(`Adopting existing preferred monitor ${current} as chosen`);
return;
}
Logger.warn(
`Preferred monitor ${current} (${
pinned ? pinned.deviceName : 'missing'
}) is ${
!pinned || !pinned.attached
? 'not attached'
: 'not the primary display'
}; healing to ${primary.index}`
);
} else if (hasCurrent && current !== ours) {
Logger.info(
`Preferred monitor ${current} was set manually; leaving it alone`
);
Preferences.data = { vmmfWrittenIndex: current };
return;
} else if (hasCurrent && current === primary.index) {
return;
}
await fs
.writeFile(vmmfCfg, `${primary.index}\n`, 'utf8')
.then(() => {
Preferences.data = { vmmfWrittenIndex: primary.index };
Logger.info(
`Preferred monitor set to ${primary.index} (${primary.deviceName} ${primary.width}x${primary.height})`
);
})
.catch(e => Logger.warn('Failed to write preferred monitor', e));
}
async verify() {
this.load();
this._notifyObservers();
@@ -112,12 +176,7 @@ class ModsClass extends Observable<ModsStatus> {
const clientDir = Preferences.data?.clientDir;
if (clientDir) {
const vmmfDll = path.join(clientDir, 'VanillaMultiMonitorFix.dll');
const vmmfCfg = path.join(clientDir, 'VMMFix_preferred_monitor.txt');
if ((await fs.pathExists(vmmfDll)) && !(await fs.pathExists(vmmfCfg))) {
const index = await detectPrimaryDisplayIndex();
await fs.writeFile(vmmfCfg, `${index}\n`, 'utf8').catch(() => {});
}
await this.#syncPreferredMonitor(clientDir);
}
for (const m of MODS) {
@@ -258,7 +317,7 @@ class ModsClass extends Observable<ModsStatus> {
if (m.source.kind === 'directFile') {
const dest = path.join(clientDir, m.source.assetName);
await this.#downloadTo(m.source.url, dest);
await this.#downloadTo(m.source.url, dest, m.source.sha256);
written.push(m.source.assetName);
} else if (m.source.kind === 'archive') {
const scratch = path.join(clientDir, '.octolauncher-tmp');
@@ -267,7 +326,7 @@ class ModsClass extends Observable<ModsStatus> {
scratch,
`${m.id}-${Date.now()}.${m.source.format}`
);
await this.#downloadTo(m.source.url, tmp);
await this.#downloadTo(m.source.url, tmp, m.source.sha256);
this.#patchRow(m.id, { state: 'installing' });
const map = m.source.extractMap;
@@ -360,7 +419,7 @@ class ModsClass extends Observable<ModsStatus> {
this.#patchRow(m.id, { state: 'idle', installedVersion: undefined });
}
async #downloadTo(url: string, dest: string) {
async #downloadTo(url: string, dest: string, sha256?: string) {
const res = await fetch(url, {
headers: { 'User-Agent': 'OctoLauncher' },
timeout: MOD_DOWNLOAD_TIMEOUT_MS
@@ -368,6 +427,15 @@ class ModsClass extends Observable<ModsStatus> {
if (!res.ok) throw new Error(`Download failed ${res.status}: ${url}`);
await fs.ensureDir(path.dirname(dest));
const buf = await res.arrayBuffer();
if (sha256) {
const got = createHash('sha256').update(Buffer.from(buf)).digest('hex');
if (got !== sha256.toLowerCase())
throw new Error(
`Checksum mismatch for ${path.basename(dest)}: expected ${sha256}, got ${got}. Refusing to install.`
);
}
await fs.writeFile(dest, Buffer.from(buf));
if (!(await fs.pathExists(dest)))
throw new Error(
+122 -17
View File
@@ -8,6 +8,7 @@ import Preferences from '~main/modules/preferences';
import { ConfigWtfSchema, type PreferencesSchema } from '~common/schemas';
import { isNotUndef } from '~common/utils';
import { fetchFile } from '~main/modules/updater';
import { enumerateDisplays } from '~main/modules/displays';
const Servers = {
live: {
@@ -32,7 +33,7 @@ type Tweak = TweakKey & {
} & (
| {
type: 'bytes';
tweaks: [number, number[]][];
tweaks: [number, number[], number[]?][];
}
| {
type: 'int8' | 'uint16' | 'float';
@@ -41,6 +42,9 @@ type Tweak = TweakKey & {
}
);
const hex = (bytes: number[]) =>
bytes.map(b => b.toString(16).padStart(2, '0')).join(' ');
export const patchExecutable = async () => {
Logger.log('Patching WoW.exe...');
@@ -127,6 +131,20 @@ export const patchExecutable = async () => {
]
]
},
{
synthetic: true,
key: 'levitateAnimRangeBounds',
type: 'bytes',
default: true,
forced: true,
tweaks: [
[
0x313d5f,
[0x57, 0x8b, 0x55, 0x0c, 0x3b, 0xd0, 0x73, 0x0e],
[0x85, 0xc0, 0x57, 0x74, 0x11, 0x8b, 0x55, 0x0c]
]
]
},
{
synthetic: true,
key: 'octowowUrlAllowlist',
@@ -137,8 +155,8 @@ export const patchExecutable = async () => {
[
0x45ccd8,
[
0x6f, 0x63, 0x74, 0x6f, 0x77, 0x6f, 0x77, 0x2e, 0x73, 0x74,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00
0x6f, 0x63, 0x74, 0x6f, 0x77, 0x6f, 0x77, 0x2e, 0x73, 0x74, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00
]
]
]
@@ -160,10 +178,25 @@ export const patchExecutable = async () => {
buffer.writeUInt16LE(t.value ?? (val as number), t.offset);
} else if (t.type === 'bytes') {
if (!t.forced && !val) return;
t.tweaks.forEach(([offset, bytes]) =>
Buffer.from(bytes).copy(buffer, offset)
t.tweaks.forEach(([offset, bytes, expect]) => {
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);
@@ -174,6 +207,48 @@ export const patchExecutable = async () => {
}
};
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 };
};
export const patchConfig = async (forceTweaks = false) => {
const { clientDir, server, config, locale } = Preferences.data;
if (!clientDir) return;
@@ -194,14 +269,19 @@ export const patchConfig = async (forceTweaks = false) => {
.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 parsed = {
const seededResolution = `${width}x${height}`;
const seed = isFirstRun
? {
scriptMemory: 512000,
gxResolution: `${width}x${height}`,
gxResolution: seededResolution,
gxColorBits: primaryDisplay.colorDepth,
gxDepthBits: primaryDisplay.colorDepth,
gxRefresh: 60,
@@ -218,6 +298,7 @@ export const patchConfig = async (forceTweaks = false) => {
specular: 1,
pixelShaders: 1,
M2UsePixelShaders: 1,
M2UseShaders: 1,
particleDensity: 1,
unitDrawDist: 300,
weatherDensity: 3,
@@ -225,19 +306,37 @@ export const patchConfig = async (forceTweaks = false) => {
minimapZoom: 0,
minimapInsideZoom: 0,
SoundZoneMusicNoDelay: 1,
patchList: configWtf['patchList'] ?? Servers[server].patchList,
realmName: configWtf['realmName'] ?? Servers[server].realmName,
gxWindow: configWtf['gxWindow'] ?? 1,
gxMaximize: configWtf['gxMaximize'] ?? 1,
gxCursor: configWtf['gxCursor'] ?? 1,
checkAddonVersion: configWtf['checkAddonVersion'] ?? 0,
farClip: configWtf['farClip'] ?? config.farClip,
CameraDistanceMax: configWtf['CameraDistanceMax'] ?? config.cameraDistance,
...configWtf,
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,
realmList: Servers[server].realmList,
patchList: configWtf['patchList'] ?? Servers[server].patchList,
realmName: configWtf['realmName'] ?? Servers[server].realmName,
hwDetect: 0,
M2UseShaders: 1,
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 }
: {})
@@ -250,5 +349,11 @@ export const patchConfig = async (forceTweaks = false) => {
const tmpPath = `${configPath}.tmp`;
await fs.writeFile(tmpPath, body);
await fs.move(tmpPath, configPath, { overwrite: true });
const chosen =
repaired.gxResolution ?? (isFirstRun ? seededResolution : undefined);
if (chosen && chosen !== Preferences.data.lastWrittenResolution)
Preferences.data = { lastWrittenResolution: chosen };
Logger.log('Config.wtf successfully patched');
};
+178 -33
View File
@@ -6,40 +6,143 @@ import { app } from 'electron';
import Logger from 'electron-log/main';
import { PreferencesSchema } from '~common/schemas';
import { DEFAULT_ENABLED_MODS } from '~common/mods';
import { omit } from '~common/utils';
const portableDir = process.env.PORTABLE_EXECUTABLE_DIR;
const errCode = (e: unknown) =>
e && typeof e === 'object' ? (e as NodeJS.ErrnoException).code : undefined;
const LOCK_CODES = ['EPERM', 'EACCES', 'EBUSY', 'EMFILE', 'ENFILE'];
const isLocked = (e: unknown) => LOCK_CODES.includes(errCode(e) ?? '');
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
const readJsonRetrying = async (file: string, attempts = 5) => {
for (let i = 0; ; i++) {
try {
return await fs.readJSON(file);
} catch (e) {
if (i >= attempts - 1 || !isLocked(e)) throw e;
await delay(60 * (i + 1));
}
}
};
const renameRetrying = async (from: string, to: string, attempts = 5) => {
for (let i = 0; ; i++) {
try {
return await fs.rename(from, to);
} catch (e) {
if (i >= attempts - 1 || !isLocked(e)) throw e;
await delay(60 * (i + 1));
}
}
};
const writeJsonAtomic = async (file: string, data: unknown) => {
const tmp = `${file}.tmp`;
await fs.writeJSON(tmp, data, { spaces: 2 });
await renameRetrying(tmp, file);
};
const dropUndefined = <T extends object>(obj: T): Partial<T> =>
Object.fromEntries(
Object.entries(obj).filter(([, v]) => v !== undefined)
) as Partial<T>;
abstract class Preferences {
static #data: z.infer<typeof PreferencesSchema>;
static #writeChain: Promise<void> = Promise.resolve();
static #readOnly = false;
static #rememberedClientDir?: string;
static #freshInstall = false;
static readonly userDataDir = process.env.PORTABLE_EXECUTABLE_DIR
? path.join(process.env.PORTABLE_EXECUTABLE_DIR, '.launcher')
: app.getPath('userData');
static async load() {
await fs.ensureDir(this.userDataDir);
const settingsPath = path.join(this.userDataDir, 'settings.json');
static readonly #settingsPath = path.join(
Preferences.userDataDir,
'settings.json'
);
let json: Record<string, unknown>;
try {
json = await fs.readJSON(settingsPath);
} catch {
return PreferencesSchema.parse({
isPortable: !!portableDir,
clientDir: portableDir
});
static readonly #installPath = path.join(
Preferences.userDataDir,
'install.json'
);
static get isFreshInstall() {
return this.#freshInstall;
}
const merged = {
static async #detectFreshInstall() {
const [settings, install, pending] = await Promise.all([
fs.pathExists(this.#settingsPath),
fs.pathExists(this.#installPath),
fs.pathExists(`${this.#settingsPath}.tmp`)
]);
return !settings && !install && !pending;
}
static #withFreshInstallDefaults(data: PreferencesSchema): PreferencesSchema {
if (!this.#freshInstall || Object.keys(data.mods).length) return data;
const mods = { ...data.mods };
for (const id of DEFAULT_ENABLED_MODS)
mods[id] = { enabled: true, installedFiles: [], ignoreUpdates: false };
Logger.info(
`Fresh install: enabling ${DEFAULT_ENABLED_MODS.join(', ')} by default`
);
return { ...data, mods };
}
static async load() {
this.#freshInstall = await this.#detectFreshInstall();
await fs.ensureDir(this.userDataDir);
const settingsPath = this.#settingsPath;
let json: Record<string, unknown> = {};
try {
json = await readJsonRetrying(settingsPath);
} catch (e) {
if (isLocked(e)) {
this.#readOnly = true;
Logger.error(
`Could not read ${settingsPath} (${errCode(e)}); running on ` +
'defaults and leaving settings untouched for this session.',
e
);
} else {
if (errCode(e) !== 'ENOENT') {
Logger.warn(`${settingsPath} is unreadable; keeping a copy`, e);
await fs
.copy(settingsPath, `${settingsPath}.corrupt`)
.catch(() => {});
}
const recovered = await fs
.readJSON(`${settingsPath}.tmp`)
.catch(() => null);
if (recovered && typeof recovered === 'object') {
Logger.warn(`Recovered settings from ${settingsPath}.tmp`);
json = recovered as Record<string, unknown>;
}
}
}
const merged = dropUndefined({
...json,
isPortable: !!portableDir,
clientDir: portableDir ?? json.clientDir
};
});
const parsed = PreferencesSchema.safeParse(merged);
if (parsed.success) return parsed.data;
if (parsed.success)
return this.#withKnownClientDir(
this.#withFreshInstallDefaults(parsed.data)
);
Logger.warn(
'settings.json failed validation; salvaging valid fields',
@@ -47,17 +150,40 @@ abstract class Preferences {
);
await fs.copy(settingsPath, `${settingsPath}.corrupt`).catch(() => {});
const salvaged: Record<string, unknown> = {
const salvaged: Record<string, unknown> = dropUndefined({
isPortable: !!portableDir,
clientDir: portableDir ?? json.clientDir
};
});
const shape = PreferencesSchema.shape;
for (const key of Object.keys(shape) as (keyof typeof shape)[]) {
if (!(key in merged)) continue;
const value = (merged as Record<string, unknown>)[key];
if (shape[key].safeParse(value).success) salvaged[key] = value;
}
return PreferencesSchema.parse(salvaged);
return this.#withKnownClientDir(PreferencesSchema.parse(salvaged));
}
static async #withKnownClientDir(data: PreferencesSchema) {
if (portableDir) return data;
const remembered = await fs
.readJSON(this.#installPath)
.then(j => {
const dir = (j as { clientDir?: unknown })?.clientDir;
return typeof dir === 'string' && dir ? dir : undefined;
})
.catch(() => undefined);
this.#rememberedClientDir = remembered;
if (await this.isValidClientDir(data.clientDir)) return data;
if (!remembered || remembered === data.clientDir) return data;
if (!(await this.isValidClientDir(remembered))) return data;
Logger.warn(
`No usable clientDir in settings.json; restored "${remembered}" from ` +
this.#installPath
);
return { ...data, clientDir: remembered };
}
static get data(): PreferencesSchema {
@@ -67,31 +193,50 @@ abstract class Preferences {
static set data(newData: Partial<Omit<PreferencesSchema, 'portableDir'>>) {
this.#data = { ...this.#data, ...newData };
const settingsPath = path.join(this.userDataDir, 'settings.json');
const delta = omit(
newData,
portableDir ? ['isPortable', 'clientDir'] : ['isPortable']
if (this.#readOnly) return;
const settingsPath = this.#settingsPath;
const dropped = portableDir ? ['isPortable', 'clientDir'] : ['isPortable'];
const delta = dropUndefined(
omit(newData, dropped as (keyof typeof newData)[])
);
const snapshot = omit(
this.#data,
portableDir ? ['isPortable', 'clientDir'] : ['isPortable']
const snapshot = dropUndefined(
omit(this.#data, dropped as (keyof PreferencesSchema)[])
);
this.#writeChain = this.#writeChain
.then(async () => {
let onDisk: unknown = null;
let base: Record<string, unknown> | null = null;
try {
onDisk = await fs.readJSON(settingsPath);
} catch {
onDisk = null;
}
const base =
const onDisk = await readJsonRetrying(settingsPath);
base =
!!onDisk && typeof onDisk === 'object' && !Array.isArray(onDisk)
? (onDisk as Record<string, unknown>)
: null;
} catch (e) {
if (isLocked(e)) {
Logger.error(
`Skipping settings write; ${settingsPath} is locked (${errCode(
e
)})`,
e
);
return;
}
}
const merged = base ? { ...base, ...delta } : snapshot;
const tmp = `${settingsPath}.tmp`;
await fs.writeJSON(tmp, merged, { spaces: 2 });
await fs.move(tmp, settingsPath, { overwrite: true });
await writeJsonAtomic(settingsPath, merged);
const clientDir = (merged as { clientDir?: unknown }).clientDir;
if (
typeof clientDir === 'string' &&
clientDir &&
clientDir !== this.#rememberedClientDir
) {
this.#rememberedClientDir = clientDir;
await writeJsonAtomic(this.#installPath, { clientDir }).catch(e =>
Logger.warn(`Failed to write ${this.#installPath}`, e)
);
}
})
.catch(e => Logger.error('Failed to persist settings.json', e));
}
+8 -1
View File
@@ -84,7 +84,7 @@ const friendlyError = (e: unknown): string => {
};
type FolderTags = 'allowExtra';
type FileTags = 'vanillaFixes';
type FileTags = 'vanillaFixes' | 'raidVisuals';
type FileManifest = { name: string } & (
| { type: 'del' }
| { type: 'dir'; files: FileManifest[]; tags?: FolderTags[] }
@@ -558,6 +558,7 @@ class UpdaterClass extends Observable<UpdaterStatus> {
try {
const vanillaFixes = Preferences.data.config.vanillaFixes;
const raidVisuals = Preferences.data.config.raidVisuals;
const modOwnedFiles = new Set<string>();
for (const state of Object.values(Preferences.data.mods ?? {}))
for (const rel of state?.installedFiles ?? [])
@@ -709,6 +710,12 @@ class UpdaterClass extends Observable<UpdaterStatus> {
}
}
if (item.tags?.includes('raidVisuals') && !raidVisuals) {
if (await fs.exists(path.join(clientPath, ...filePath)))
return { type: 'del', name: item.name };
return undefined;
}
this.status = {
state: 'verifying',
progress: i / totalSize,
+12 -1
View File
@@ -10,6 +10,7 @@ if (!port) throw new Error('IllegalState');
const { dir, url, ref } = workerData;
const tmpDir = `${dir}.tmp`;
const bakDir = `${dir}.bak`;
const run = async () => {
await fs.remove(tmpDir);
@@ -23,8 +24,18 @@ const run = async () => {
onProgress: (...args) => port.postMessage({ cb: 'onProgress', args })
});
await fs.remove(dir);
await fs.remove(bakDir);
const hadExisting = await fs.pathExists(dir);
if (hadExisting) await fs.move(dir, bakDir);
try {
await fs.move(tmpDir, dir);
} catch (e) {
if (hadExisting) await fs.move(bakDir, dir).catch(() => undefined);
throw e;
}
await fs.remove(bakDir).catch(() => undefined);
};
run()
+30 -1
View File
@@ -1,5 +1,5 @@
import { useForm } from 'react-hook-form';
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { PreferencesSchema } from '~common/schemas';
import zodResolver from '~renderer/utils/zodResolver';
@@ -8,6 +8,7 @@ import { useT } from '~renderer/i18n';
import TextButton from './styled/TextButton';
import FilePickerInput from './form/FilePickerInput';
import CheckboxInput from './form/CheckboxInput';
import CloseButton from './styled/CloseButton';
type Props = { close: () => void };
@@ -36,6 +37,19 @@ const ClientDirDialog = ({ close }: Props) => {
resolver: zodResolver(PreferencesSchema.pick({ clientDir: true }))
});
const chosen = watch('clientDir');
const [acceptEmpty, setAcceptEmpty] = useState(false);
const chosenIsClient = api.preferences.isValidClientDir.useQuery(chosen, {
enabled: !!chosen && !pref?.isPortable
});
const needsEmptyConfirm =
!!chosen && chosenIsClient.isFetched && chosenIsClient.data === false;
useEffect(() => {
setAcceptEmpty(false);
}, [chosen]);
useEffect(() => {
pref && reset(pref);
}, [reset, pref]);
@@ -62,6 +76,7 @@ const ClientDirDialog = ({ close }: Props) => {
<form
className="tw-dialog"
onSubmit={handleSubmit(async ({ clientDir }) => {
if (needsEmptyConfirm && !acceptEmpty) return;
try {
await setPref.mutateAsync({ clientDir });
verify.mutate();
@@ -105,9 +120,23 @@ const ClientDirDialog = ({ close }: Props) => {
</p>
)}
{needsEmptyConfirm && (
<>
<p className="text-secondary text-sm">
{t('prefs.noClientHere', { exe: 'WoW.exe' })}
</p>
<CheckboxInput
value={acceptEmpty}
setValue={setAcceptEmpty}
label={t('prefs.noClientHereConfirm')}
/>
</>
)}
<TextButton
type="submit"
loading={formState.isSubmitting}
disabled={needsEmptyConfirm && !acceptEmpty}
className="self-end text-green"
>
{t('prefs.confirm')}
-4
View File
@@ -50,7 +50,6 @@ const NewsEntry = ({ item }: { item: NewsItem }) => {
);
};
// The "Announcements" list — most-recent forum topics as short previews + links.
const AnnouncementsBox = () => {
const t = useT();
const query = api.news.list.useQuery(undefined, {
@@ -108,9 +107,6 @@ const AnnouncementsBox = () => {
);
};
// The News tab holds both boxes side by side: the parchment "newsletter" (the
// featured Nautilus News Network post, biggest) and the "Announcements" list.
// Living inside the tab means they only show on News — not on Tweaks/Addons/Mods.
const NewsTab = () => (
<div className="flex min-h-0 flex-grow gap-3">
<ForumAnnouncementPanel />
+15 -2
View File
@@ -117,6 +117,12 @@ const TweaksTab = () => {
label={t('tweaks.alwaysAutoLoot.label')}
text={t('tweaks.alwaysAutoLoot.text')}
/>
<Item
form={form}
id="raidVisuals"
label={t('tweaks.raidVisuals.label')}
text={t('tweaks.raidVisuals.text')}
/>
<Item
form={form}
id="largeAddress"
@@ -214,8 +220,15 @@ const TweaksTab = () => {
onClick={async () => {
const config =
recommendedFarClip != null
? { ...ConfigWtfSchema.parse({}), farClip: recommendedFarClip }
: ConfigWtfSchema.parse({});
? {
...ConfigWtfSchema.parse({}),
farClip: recommendedFarClip,
raidVisuals: form.getValues('raidVisuals')
}
: {
...ConfigWtfSchema.parse({}),
raidVisuals: form.getValues('raidVisuals')
};
await setPref.mutateAsync({ config, farClipUserSet: false });
reset(config);
}}
+39
View File
@@ -34,6 +34,9 @@ const enUS: Dict = {
'launch.remaining': 'remaining',
'launch.calculating': 'calculating…',
'launch.onDisk': 'on disk',
'tweaks.raidVisuals.label': 'Updated Raid Visuals',
'tweaks.raidVisuals.text':
'Optional ~9 MB download. Adds clearer ground markers and sounds for raid boss abilities, kept in sync with the server automatically. (AKA Patch-O)',
'tweaks.alwaysAutoLoot.label': 'Always auto-loot',
'tweaks.alwaysAutoLoot.text':
'Reverses auto-loot behavior to always auto-loot and disable auto-with bound key.',
@@ -224,6 +227,10 @@ const enUS: Dict = {
'prefs.upgradeExisting':
'You may also choose a directory with an existing Turtle WoW or Vanilla WoW installation, and it will be automatically upgraded.',
'prefs.installDirectory': 'Install directory:',
'prefs.noClientHere':
'No {exe} in this folder. The launcher will download a fresh client here, and it will not contain your existing addons or settings. If you already have an install, pick that folder instead.',
'prefs.noClientHereConfirm':
'I understand — download a fresh client into this folder',
'prefs.confirm': 'Confirm'
};
@@ -260,6 +267,9 @@ const deDE: Dict = {
'launch.remaining': 'verbleibend',
'launch.calculating': 'wird berechnet…',
'launch.onDisk': 'auf der Festplatte',
'tweaks.raidVisuals.label': 'Aktualisierte Raid-Effekte',
'tweaks.raidVisuals.text':
'Optionaler Download (~9 MB). Fügt deutlichere Bodenmarkierungen und Sounds für Raidboss-Fähigkeiten hinzu, automatisch mit dem Server synchron gehalten. (auch bekannt als Patch-O)',
'tweaks.alwaysAutoLoot.label': 'Immer automatisch plündern',
'tweaks.alwaysAutoLoot.text':
'Kehrt das Auto-Plündern-Verhalten um, sodass immer automatisch geplündert wird und das Auto-Plündern per Tastenkombination deaktiviert ist.',
@@ -429,6 +439,10 @@ const deDE: Dict = {
'prefs.upgradeExisting':
'Du kannst auch ein Verzeichnis mit einer vorhandenen Turtle-WoW- oder Vanilla-WoW-Installation wählen, und es wird automatisch aktualisiert.',
'prefs.installDirectory': 'Installationsverzeichnis:',
'prefs.noClientHere':
'In diesem Ordner ist keine {exe}. Der Launcher lädt hier einen neuen Client herunter, der deine vorhandenen Addons und Einstellungen nicht enthält. Wenn du bereits eine Installation hast, wähle stattdessen deren Ordner.',
'prefs.noClientHereConfirm':
'Verstanden — einen neuen Client in diesen Ordner herunterladen',
'prefs.confirm': 'Bestätigen',
'misc.newsTitle': 'Neuigkeiten',
'misc.newsByAuthor': 'von {author}',
@@ -485,6 +499,9 @@ const zhCN: Dict = {
'launch.remaining': '剩余',
'launch.calculating': '计算中…',
'launch.onDisk': '在磁盘上',
'tweaks.raidVisuals.label': '团队副本视觉增强',
'tweaks.raidVisuals.text':
'可选下载(约 9 MB)。为团队首领技能添加更清晰的地面标记和音效,并自动与服务器保持同步。(又称 Patch-O)',
'tweaks.alwaysAutoLoot.label': '始终自动拾取',
'tweaks.alwaysAutoLoot.text':
'反转自动拾取行为,改为始终自动拾取,并禁用按住绑定键的自动拾取。',
@@ -638,6 +655,9 @@ const zhCN: Dict = {
'prefs.upgradeExisting':
'你也可以选择一个已有 Turtle WoW 或 Vanilla WoW 安装的目录,它将被自动升级。',
'prefs.installDirectory': '安装目录:',
'prefs.noClientHere':
'此文件夹中没有 {exe}。启动器将在此处下载全新的客户端,其中不会包含你现有的插件和设置。如果你已经安装过,请改为选择原有的安装目录。',
'prefs.noClientHereConfirm': '我已了解——在此文件夹下载全新客户端',
'prefs.confirm': '确认',
'misc.newsTitle': '新闻',
'misc.newsByAuthor': '作者:{author}',
@@ -693,6 +713,9 @@ const esES: Dict = {
'launch.remaining': 'restante',
'launch.calculating': 'calculando…',
'launch.onDisk': 'en disco',
'tweaks.raidVisuals.label': 'Efectos de banda mejorados',
'tweaks.raidVisuals.text':
'Descarga opcional de ~9 MB. Añade marcadores de suelo y sonidos más claros para las habilidades de los jefes de banda, sincronizados automáticamente con el servidor. (también conocido como Patch-O)',
'tweaks.alwaysAutoLoot.label': 'Saqueo automático siempre',
'tweaks.alwaysAutoLoot.text':
'Invierte el comportamiento del saqueo automático para saquear siempre de forma automática y desactivar el saqueo automático con tecla asignada.',
@@ -861,6 +884,10 @@ const esES: Dict = {
'prefs.upgradeExisting':
'También puedes elegir un directorio con una instalación existente de Turtle WoW o Vanilla WoW, y se actualizará automáticamente.',
'prefs.installDirectory': 'Directorio de instalación:',
'prefs.noClientHere':
'No hay ningún {exe} en esta carpeta. El launcher descargará aquí un cliente nuevo, que no incluirá tus addons ni tu configuración actuales. Si ya tienes una instalación, elige esa carpeta.',
'prefs.noClientHereConfirm':
'Lo entiendo: descargar un cliente nuevo en esta carpeta',
'prefs.confirm': 'Confirmar',
'misc.newsTitle': 'Noticias',
'misc.newsByAuthor': 'por {author}',
@@ -919,6 +946,9 @@ const ptBR: Dict = {
'launch.remaining': 'restante',
'launch.calculating': 'calculando…',
'launch.onDisk': 'no disco',
'tweaks.raidVisuals.label': 'Efeitos de raide atualizados',
'tweaks.raidVisuals.text':
'Download opcional de ~9 MB. Adiciona marcações de chão e sons mais claros para as habilidades dos chefes de raide, mantidos em sincronia com o servidor automaticamente. (também conhecido como Patch-O)',
'tweaks.alwaysAutoLoot.label': 'Saque automático sempre ativo',
'tweaks.alwaysAutoLoot.text':
'Inverte o comportamento do saque automático para saquear sempre automaticamente e desativa o saque automático com a tecla atribuída.',
@@ -1086,6 +1116,9 @@ const ptBR: Dict = {
'prefs.upgradeExisting':
'Você também pode escolher um diretório com uma instalação existente do Turtle WoW ou Vanilla WoW, que será atualizada automaticamente.',
'prefs.installDirectory': 'Diretório de instalação:',
'prefs.noClientHere':
'Não há {exe} nesta pasta. O launcher vai baixar um cliente novo aqui, sem os seus addons e configurações atuais. Se você já tem uma instalação, escolha a pasta dela.',
'prefs.noClientHereConfirm': 'Entendi — baixar um cliente novo nesta pasta',
'prefs.confirm': 'Confirmar',
'misc.newsTitle': 'Notícias',
'misc.newsByAuthor': 'por {author}',
@@ -1142,6 +1175,9 @@ const ruRU: Dict = {
'launch.remaining': 'осталось',
'launch.calculating': 'вычисление…',
'launch.onDisk': 'на диске',
'tweaks.raidVisuals.label': 'Улучшенные эффекты рейдов',
'tweaks.raidVisuals.text':
'Дополнительная загрузка (~9 МБ). Добавляет более заметные отметки на земле и звуки для способностей рейдовых боссов, автоматически синхронизируется с сервером. (также известен как Patch-O)',
'tweaks.alwaysAutoLoot.label': 'Всегда автосбор',
'tweaks.alwaysAutoLoot.text':
'Меняет поведение автосбора на противоположное: всегда автоматически собирать добычу, а ручной сбор включается зажатой клавишей.',
@@ -1305,6 +1341,9 @@ const ruRU: Dict = {
'prefs.upgradeExisting':
'Вы также можете выбрать папку с уже установленным Turtle WoW или Vanilla WoW, и она будет автоматически обновлена.',
'prefs.installDirectory': 'Папка установки:',
'prefs.noClientHere':
'В этой папке нет {exe}. Лаунчер скачает сюда новый клиент, в котором не будет ваших текущих аддонов и настроек. Если игра уже установлена, выберите её папку.',
'prefs.noClientHereConfirm': 'Понятно — скачать новый клиент в эту папку',
'prefs.confirm': 'Подтвердить',
'misc.newsTitle': 'Новости',
'misc.newsByAuthor': 'от {author}',