diff --git a/package.json b/package.json index d264763..737745f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/scrub-native-paths.cjs b/scripts/scrub-native-paths.cjs new file mode 100644 index 0000000..13aad03 --- /dev/null +++ b/scripts/scrub-native-paths.cjs @@ -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}`); diff --git a/server/src/cache.ts b/server/src/cache.ts index f78c01c..64cbb1e 100644 --- a/server/src/cache.ts +++ b/server/src/cache.ts @@ -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', diff --git a/src/common/mods.ts b/src/common/mods.ts index d003629..b8aca4a 100644 --- a/src/common/mods.ts +++ b/src/common/mods.ts @@ -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; + 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); diff --git a/src/common/schemas.ts b/src/common/schemas.ts index c49b897..2354536 100644 --- a/src/common/schemas.ts +++ b/src/common/schemas.ts @@ -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({ diff --git a/src/main/api/routers/launcher.ts b/src/main/api/routers/launcher.ts index e1c875d..48b99aa 100644 --- a/src/main/api/routers/launcher.ts +++ b/src/main/api/routers/launcher.ts @@ -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 }); diff --git a/src/main/index.ts b/src/main/index.ts index 98507ae..a1c0716 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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(); }); diff --git a/src/main/modules/addons.ts b/src/main/modules/addons.ts index 12472a0..266121c 100644 --- a/src/main/modules/addons.ts +++ b/src/main/modules/addons.ts @@ -195,7 +195,7 @@ class AddonsClass extends Observable { : []; 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 }]) ); diff --git a/src/main/modules/displays.ts b/src/main/modules/displays.ts index a1ee0eb..b43f074 100644 --- a/src/main/modules/displays.ts +++ b/src/main/modules/displays.ts @@ -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);', '}', "'@", - '$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 }', - '}', - 'exit 1' + 'for ($i = 0; ; $i++) {', + ' $dd = New-Object VmmfDisplays+DISPLAY_DEVICE', + ' $dd.cb = [System.Runtime.InteropServices.Marshal]::SizeOf($dd)', + ' 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)"', + ' }', + ' $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 => { - 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 => { + 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 => { 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 => { + const devices = await enumerateDisplays(); + return devices?.find(d => d.primary && d.attached)?.index ?? null; +}; diff --git a/src/main/modules/localePatch.ts b/src/main/modules/localePatch.ts index 7f9e166..2e801c6 100644 --- a/src/main/modules/localePatch.ts +++ b/src/main/modules/localePatch.ts @@ -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(() => {}); diff --git a/src/main/modules/mods.ts b/src/main/modules/mods.ts index d8eaf59..1e1ba04 100644 --- a/src/main/modules/mods.ts +++ b/src/main/modules/mods.ts @@ -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 { }; } + 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 { 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 { 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 { 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 { 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 { 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( diff --git a/src/main/modules/patcher.ts b/src/main/modules/patcher.ts index afd7aa6..2596433 100644 --- a/src/main/modules/patcher.ts +++ b/src/main/modules/patcher.ts @@ -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,9 +178,24 @@ 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.' + ); + }); } }); @@ -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 ?? ''} 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,50 +269,74 @@ 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 = { - scriptMemory: 512000, - gxResolution: `${width}x${height}`, - 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, - particleDensity: 1, - unitDrawDist: 300, - weatherDensity: 3, - movieSubtitle: 1, - 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, + 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, 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'); }; diff --git a/src/main/modules/preferences.ts b/src/main/modules/preferences.ts index 52dd534..8ccd2c2 100644 --- a/src/main/modules/preferences.ts +++ b/src/main/modules/preferences.ts @@ -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 = (obj: T): Partial => + Object.fromEntries( + Object.entries(obj).filter(([, v]) => v !== undefined) + ) as Partial; + abstract class Preferences { static #data: z.infer; static #writeChain: Promise = 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; + static readonly #installPath = path.join( + Preferences.userDataDir, + 'install.json' + ); + + static get isFreshInstall() { + return this.#freshInstall; + } + + 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 = {}; try { - json = await fs.readJSON(settingsPath); - } catch { - return PreferencesSchema.parse({ - isPortable: !!portableDir, - clientDir: portableDir - }); + 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; + } + } } - const merged = { + 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 = { + const salvaged: Record = 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)[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>) { 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 | null = null; try { - onDisk = await fs.readJSON(settingsPath); - } catch { - onDisk = null; + const onDisk = await readJsonRetrying(settingsPath); + base = + !!onDisk && typeof onDisk === 'object' && !Array.isArray(onDisk) + ? (onDisk as Record) + : null; + } catch (e) { + if (isLocked(e)) { + Logger.error( + `Skipping settings write; ${settingsPath} is locked (${errCode( + e + )})`, + e + ); + return; + } } - const base = - !!onDisk && typeof onDisk === 'object' && !Array.isArray(onDisk) - ? (onDisk as Record) - : null; 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)); } diff --git a/src/main/modules/updater.ts b/src/main/modules/updater.ts index 08cc171..ae6abdb 100644 --- a/src/main/modules/updater.ts +++ b/src/main/modules/updater.ts @@ -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 { try { const vanillaFixes = Preferences.data.config.vanillaFixes; + const raidVisuals = Preferences.data.config.raidVisuals; const modOwnedFiles = new Set(); for (const state of Object.values(Preferences.data.mods ?? {})) for (const rel of state?.installedFiles ?? []) @@ -709,6 +710,12 @@ class UpdaterClass extends Observable { } } + 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, diff --git a/src/main/workers/gitClone.ts b/src/main/workers/gitClone.ts index 31593e8..42ae74f 100644 --- a/src/main/workers/gitClone.ts +++ b/src/main/workers/gitClone.ts @@ -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.move(tmpDir, 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() diff --git a/src/renderer/components/ClientDirDialog.tsx b/src/renderer/components/ClientDirDialog.tsx index b0173d4..9462a9a 100644 --- a/src/renderer/components/ClientDirDialog.tsx +++ b/src/renderer/components/ClientDirDialog.tsx @@ -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) => {
{ + if (needsEmptyConfirm && !acceptEmpty) return; try { await setPref.mutateAsync({ clientDir }); verify.mutate(); @@ -105,9 +120,23 @@ const ClientDirDialog = ({ close }: Props) => {

)} + {needsEmptyConfirm && ( + <> +

+ {t('prefs.noClientHere', { exe: 'WoW.exe' })} +

+ + + )} + {t('prefs.confirm')} diff --git a/src/renderer/components/tabs/NewsTab.tsx b/src/renderer/components/tabs/NewsTab.tsx index b35949e..f1f9160 100644 --- a/src/renderer/components/tabs/NewsTab.tsx +++ b/src/renderer/components/tabs/NewsTab.tsx @@ -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 = () => (
diff --git a/src/renderer/components/tabs/TweaksTab.tsx b/src/renderer/components/tabs/TweaksTab.tsx index 65c287d..67b56b2 100644 --- a/src/renderer/components/tabs/TweaksTab.tsx +++ b/src/renderer/components/tabs/TweaksTab.tsx @@ -117,6 +117,12 @@ const TweaksTab = () => { label={t('tweaks.alwaysAutoLoot.label')} text={t('tweaks.alwaysAutoLoot.text')} /> + { 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); }} diff --git a/src/renderer/i18n/translations.ts b/src/renderer/i18n/translations.ts index 803d9c9..f6e639c 100644 --- a/src/renderer/i18n/translations.ts +++ b/src/renderer/i18n/translations.ts @@ -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}',