From f9d89601f140053e7ef113d42a84da63f0c041f7 Mon Sep 17 00:00:00 2001 From: OctoWoW Date: Fri, 14 Aug 2026 11:37:18 +0000 Subject: [PATCH] OctoLauncher 1.3.5 --- .env.production | 2 + electron-builder.yml | 4 +- package.json | 2 +- src/common/mods.ts | 11 +++-- src/main/api/routers/patcher.ts | 15 ++++-- src/main/modules/aria2.ts | 21 +++++++++ src/main/modules/defender.ts | 9 +++- src/main/modules/mods.ts | 83 ++++++++++++++++++++++++++++++--- src/main/modules/patcher.ts | 83 +++++++++++++++++++++++++++++++-- src/main/modules/preferences.ts | 8 ++-- src/main/modules/updater.ts | 30 +++++++++++- 11 files changed, 239 insertions(+), 29 deletions(-) diff --git a/.env.production b/.env.production index 93d2765..d7a8dce 100644 --- a/.env.production +++ b/.env.production @@ -1,2 +1,4 @@ MAIN_VITE_SERVER_URL=https://octowow.st MAIN_VITE_CLIENT_VERSION=latest +MAIN_VITE_CLIENT_TORRENT_URL=https://dl.octowow.st/download/client.torrent +MAIN_VITE_RAID_VISUALS_URL=https://dl.octowow.st/client/latest/Data/patch-O.mpq diff --git a/electron-builder.yml b/electron-builder.yml index ed202c4..6d33828 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -36,10 +36,10 @@ extraResources: win: artifactName: ${productName}.${ext} target: - - portable - nsis nsis: - artifactName: ${productName}_Installer.${ext} + # versioned: differential updates need the old blockmap to stay fetchable + artifactName: ${productName}_Installer-${version}.${ext} uninstallDisplayName: ${productName} oneClick: false removeDefaultUninstallWelcomePage: true diff --git a/package.json b/package.json index bec8ead..980bb38 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "octo-launcher", - "version": "1.3.1", + "version": "1.3.5", "description": "An Electron application for launching and updating the OctoWoW client", "author": "OctoWoW", "copyright": "Copyright © 2026 OctoWoW", diff --git a/src/common/mods.ts b/src/common/mods.ts index 11bc58f..49a551b 100644 --- a/src/common/mods.ts +++ b/src/common/mods.ts @@ -207,8 +207,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 => !m.disabled && !NOT_DEFAULT_ENABLED.includes(m.id) -).map(m => m.id); +// fallback for profiles with no stored state: enabled, so legacy installs +// keep their mods; fresh installs seed explicit off rows instead (do NOT +// flip this list to change defaults, it strips mods from legacy profiles) +export const DEFAULT_ENABLED_MODS: ModId[] = MODS.filter(m => !m.disabled).map( + m => m.id +); diff --git a/src/main/api/routers/patcher.ts b/src/main/api/routers/patcher.ts index 8a34ad1..8498822 100644 --- a/src/main/api/routers/patcher.ts +++ b/src/main/api/routers/patcher.ts @@ -2,14 +2,21 @@ import { patchConfig, patchExecutable } from '~main/modules/patcher'; import Preferences from '~main/modules/preferences'; import Updater from '~main/modules/updater'; import { getClientVersion } from '~main/utils'; +import { stopSeeding } from '~main/modules/aria2'; import { createTRPCRouter, publicProcedure } from '../trpc'; export const patcherRouter = createTRPCRouter({ apply: publicProcedure.mutation(async () => { - await patchExecutable(); - await patchConfig(true); - await Updater.recordPatchedWow(); - Preferences.data = { version: await getClientVersion() }; + // release the seeder's file handles so the patchers can write + stopSeeding(); + try { + await patchExecutable(); + await patchConfig(true); + await Updater.recordPatchedWow(); + Preferences.data = { version: await getClientVersion() }; + } finally { + await Updater.refreshSeeding(); + } }) }); diff --git a/src/main/modules/aria2.ts b/src/main/modules/aria2.ts index b033a4a..6bca30b 100644 --- a/src/main/modules/aria2.ts +++ b/src/main/modules/aria2.ts @@ -269,6 +269,16 @@ const torrentDataDirs = (torrentBytes: Buffer): Set => { ); }; +// archives the old client shipped under names the current one no longer uses; +// matched by name AND exact size so player mods reusing a name are never touched +const LEGACY_ARCHIVES: Record = { + 'patch-6.mpq': 451195806, + 'patch-7.mpq': 175256564, + 'patch-8.mpq': 484649870, + 'patch-9.mpq': 506808141, + 'patch-a.mpq': 241751337 +}; + export const pruneStaleArchives = async ( clientDir: string, url: string, @@ -299,6 +309,7 @@ export const pruneStaleArchives = async ( continue; } if (!/\.mpq$/i.test(name) || expected.has(lc)) continue; + if (LEGACY_ARCHIVES[lc] !== st.size) continue; await fs.remove(full); removed.push(name); } @@ -309,6 +320,12 @@ export const pruneStaleArchives = async ( } }; +// files the torrent ships but a mod toggle owns; the sync must not re-add +// them or count their absence as an incomplete tree +const LAUNCHER_OWNED_FILES = new Set(['d3d9.dll']); +const isLauncherOwned = (parts: string[]) => + parts.length === 1 && LAUNCHER_OWNED_FILES.has(parts[0].toLowerCase()); + export const torrentDownloadSelection = async ( clientDir: string, url: string, @@ -327,6 +344,7 @@ export const torrentDownloadSelection = async ( for (let i = 0; i < files.length; i++) { const f = files[i]; if (!f.path?.length || typeof f.length !== 'number') return null; + if (isLauncherOwned(f.path)) continue; const dest = path.join(clientDir, ...f.path); const st = await fs.stat(dest).catch(() => null); if (!st) { @@ -361,6 +379,7 @@ export const torrentTreeIntact = async ( if (!files.length) return false; for (const f of files) { if (!f.path?.length || typeof f.length !== 'number') return false; + if (isLauncherOwned(f.path)) continue; const st = await fs .stat(path.join(clientDir, ...f.path)) .catch(() => null); @@ -439,6 +458,8 @@ export const startSeeding = async ( '--bt-seed-unverified=true', `--seed-time=${SEED_TIME_MINUTES}`, '--check-integrity=false', + // no prealloc: the seeder must not recreate missing files as zeros + '--file-allocation=none', '--continue=true', '--bt-save-metadata=true', '--enable-dht=true', diff --git a/src/main/modules/defender.ts b/src/main/modules/defender.ts index 0b21c2d..55b7a0e 100644 --- a/src/main/modules/defender.ts +++ b/src/main/modules/defender.ts @@ -145,8 +145,15 @@ export const detectAntivirusBlocks = async (): Promise => { const blocked = new Set(); if (clientDir && Preferences.data.syncedTorrentHash) - for (const name of SENSITIVE_FILES) + for (const name of SENSITIVE_FILES) { + // d3d9.dll is deliberately parked while dxvk is off, not blocked + if ( + name === 'd3d9.dll' && + Preferences.data.mods?.dxvk?.enabled === false + ) + continue; if (!fs.existsSync(path.join(clientDir, name))) blocked.add(name); + } const script = 'Get-MpThreatDetection | Where-Object ' + diff --git a/src/main/modules/mods.ts b/src/main/modules/mods.ts index d59abd3..c92d92a 100644 --- a/src/main/modules/mods.ts +++ b/src/main/modules/mods.ts @@ -17,7 +17,7 @@ import { import { type ModState } from '~common/schemas'; import Preferences from './preferences'; -import { isTorrentMode } from './aria2'; +import { isTorrentMode, stopSeeding } from './aria2'; import Observable from './observable'; import Updater from './updater'; import { addDll, removeDll, listDlls } from './dllsTxt'; @@ -287,6 +287,33 @@ class ModsClass extends Observable { // torrent mode: DLLs ship in the client; a missing file goes to `missing`, not dirty if (isTorrentMode()) { + const enabled = state?.enabled ?? DEFAULT_ENABLED_MODS.includes(m.id); + // dxvk loads by file presence, not dlls.txt; disabled parks + // d3d9.dll as .off, enabled restores it + if (m.id === 'dxvk' && clientDir) { + const live = path.join(clientDir, 'd3d9.dll'); + const off = path.join(clientDir, 'd3d9.dll.off'); + const liveStat = await fs.stat(live).catch(() => null); + if (!enabled && liveStat && liveStat.size === 0) { + // 0-byte aria2 stub, never park it over a real copy + await fs.remove(live).catch(() => undefined); + } else if (!enabled && liveStat) { + await fs.remove(off).catch(() => undefined); + await fs + .move(live, off) + .then(() => Logger.info('dxvk disabled: parked d3d9.dll')) + .catch(e => Logger.warn('Could not park d3d9.dll', e)); + } else if ( + enabled && + !(await fs.pathExists(live)) && + (await fs.pathExists(off)) + ) { + await fs + .move(off, live) + .then(() => Logger.info('dxvk enabled: restored d3d9.dll')) + .catch(e => Logger.warn('Could not restore d3d9.dll', e)); + } + } const files = modTargetFiles(m); const present = !!clientDir && @@ -296,7 +323,6 @@ class ModsClass extends Observable { files.map(rel => fs.pathExists(path.join(clientDir, rel))) ) ).every(Boolean); - const enabled = state?.enabled ?? DEFAULT_ENABLED_MODS.includes(m.id); installedVersion = enabled ? m.version : undefined; if (enabled && files.length > 0 && !present) missing.push(m.name); // only point dlls.txt at a file actually on disk @@ -452,9 +478,15 @@ class ModsClass extends Observable { } // commit the player's own DLL toggles first await this.#applyCustomDlls(clientDir); - // torrent mode: mods ship in the client; just reconcile dlls.txt + // torrent mode: mods ship in the client; reconcile dlls.txt. The + // seeder holds files open, so release it for the dxvk park/restore. if (isTorrentMode()) { - await this.verify(); + stopSeeding(); + try { + await this.verify(); + } finally { + await Updater.refreshSeeding().catch(() => undefined); + } return; } if (this._value.state === 'busy') { @@ -508,9 +540,33 @@ class ModsClass extends Observable { } async #install(m: ModEntry) { - // In torrent mode the mod binaries ship with the client; nothing is fetched. - if (isTorrentMode()) return; const clientDir = Preferences.data?.clientDir; + // dxvk: restoring a parked copy is the only enable path that works in + // torrent mode (nothing is fetched there, the sync ignores d3d9.dll) + if (m.id === 'dxvk' && clientDir) { + const live = path.join(clientDir, 'd3d9.dll'); + const off = path.join(clientDir, 'd3d9.dll.off'); + if (!(await fs.pathExists(live)) && (await fs.pathExists(off))) { + Logger.info('Restoring parked d3d9.dll for dxvk'); + await fs.move(off, live); + await this.#savePref(m.id, { + enabled: true, + installedVersion: m.version, + installedFiles: ['d3d9.dll'], + ignoreUpdates: + Preferences.data?.mods?.[m.id]?.ignoreUpdates ?? false + }); + this.#patchRow(m.id, { + state: 'idle', + installedVersion: m.version, + progress: 1 + }); + return; + } + } + // torrent mode ships mod binaries with the client; dxvk is the + // exception (unsynced), a fresh enable with no parked copy downloads + if (isTorrentMode() && m.id !== 'dxvk') return; if (!clientDir) throw new Error('No client dir'); if (m.source.kind === 'managed') return; @@ -605,7 +661,20 @@ class ModsClass extends Observable { this.#patchRow(m.id, { state: 'uninstalling', error: undefined }); const cur = Preferences.data?.mods?.[m.id]; - const files = cur?.installedFiles ?? []; + // dxvk: park instead of delete so re-enable is instant and offline + const files = [...(cur?.installedFiles ?? [])].filter( + f => !(m.id === 'dxvk' && /d3d9\.dll$/i.test(f)) + ); + if (m.id === 'dxvk') { + const live = path.join(clientDir, 'd3d9.dll'); + const off = path.join(clientDir, 'd3d9.dll.off'); + if (await fs.pathExists(live)) { + await fs.remove(off).catch(() => undefined); + await fs + .move(live, off) + .catch(err => Logger.warn(`Couldn't park ${live}:`, err)); + } + } for (const rel of files) { const fullPath = path.join(clientDir, rel); diff --git a/src/main/modules/patcher.ts b/src/main/modules/patcher.ts index b381f16..444dea1 100644 --- a/src/main/modules/patcher.ts +++ b/src/main/modules/patcher.ts @@ -85,6 +85,42 @@ export const patchExecutable = async () => { const loc = LOCALES[locale]; + // revert any previous locale patch to the pristine bytes first, so a + // language switch (or an adopted pre-patched exe) can re-patch cleanly + const TAG_OFFSET = 0x1b2115; + const INDEX_OFFSET = 0x253c; + const PRISTINE_TAG = [0xa1, 0xa4, 0xa2, 0xc2, 0x00]; + const PRISTINE_INDEX = [0x33, 0xf6, 0x8b, 0xff, 0x8b, 0x04, 0xb5]; + if ( + buffer[TAG_OFFSET] === 0xb8 && + buffer[INDEX_OFFSET] === 0xbe && + buffer[INDEX_OFFSET + 5] === 0xeb + ) { + const prevIndex = buffer[INDEX_OFFSET + 1]; + const prevCarrier = LOCALE_NAMES[prevIndex] as string | undefined; + const prevTag = prevCarrier + ? Buffer.from([ + 0xb8, + ...Buffer.from(prevCarrier, 'latin1').reverse() + ]) + : undefined; + if ( + prevCarrier && + prevTag && + buffer.subarray(TAG_OFFSET, TAG_OFFSET + 5).equals(prevTag) + ) { + Logger.log( + `Reverting previous locale patch (index ${prevIndex}) to the clean base` + ); + Buffer.from(PRISTINE_TAG).copy(buffer, TAG_OFFSET); + Buffer.from(PRISTINE_INDEX).copy(buffer, INDEX_OFFSET); + Buffer.from(prevCarrier, 'latin1').copy( + buffer, + localeNameOffset(prevIndex) + ); + } + } + const Tweaks = [ { key: 'largeAddress', @@ -110,11 +146,12 @@ export const patchExecutable = async () => { default: false }, { + // shipped exe carries the enabled bytes; off must write 0x74 back key: 'alwaysAutoLoot', type: 'bytes', tweaks: [ - [0x0c1ecf, [0x75]], - [0x0c2b25, [0x75]] + [0x0c1ecf, [0x75], [0x74]], + [0x0c2b25, [0x75], [0x74]] ] }, { key: 'nameplateRange', type: 'float', offset: 0x40c448 }, @@ -223,7 +260,22 @@ export const patchExecutable = async () => { if (!t.forced && !val) return; buffer.writeUInt16LE(t.value ?? (val as number), t.offset); } else if (t.type === 'bytes') { - if (!t.forced && !val) return; + if (!t.forced && !val) { + // disabled: revert sites carrying the enabled bytes to the + // stock bytes when known; unknown bytes stay untouched + t.tweaks.forEach( + ([offset, bytes, expect]: [number, number[], number[]?]) => { + if (!expect) return; + const current = buffer.subarray( + offset, + offset + bytes.length + ); + if (current.equals(Buffer.from(bytes))) + Buffer.from(expect).copy(buffer, offset); + } + ); + return; + } t.tweaks.forEach( ([offset, bytes, expect]: [number, number[], number[]?]) => { if (expect) { @@ -309,6 +361,11 @@ const repairResolution = async ( const applyRealmlist = async (clientDir: string, host: string) => { const body = `set realmlist "${host}"\n`; const write = async (target: string) => { + // already correct: leave it alone (the seeder may hold the file open) + const current = await fs + .readFile(target, { encoding: 'utf-8' }) + .catch(() => null); + if (current === body) return; const tmp = `${target}.tmp`; try { await fs.writeFile(tmp, body); @@ -332,6 +389,26 @@ const applyRealmlist = async (clientDir: string, host: string) => { } }; +// rewrite realmlist.wtf when missing, empty, or wrong; an interrupted sync +// can leave a 0-byte placeholder that disconnects direct game launches +export const healRealmlist = async (clientDir: string) => { + const server: keyof typeof Servers = import.meta.env.MAIN_VITE_PTR_REALMLIST + ? 'ptr' + : 'live'; + const expected = `set realmlist "${Servers[server].realmList}"\n`; + const target = path.join(clientDir, 'realmlist.wtf'); + const current = await fs + .readFile(target, { encoding: 'utf-8' }) + .catch(() => null); + if (current === expected) return; + Logger.log( + `realmlist.wtf ${ + current === null ? 'missing' : current.trim() ? 'wrong' : 'empty' + }; rewriting` + ); + await applyRealmlist(clientDir, Servers[server].realmList); +}; + export const patchConfig = async (forceTweaks = false) => { const { clientDir, config, locale } = Preferences.data; if (!clientDir) return; diff --git a/src/main/modules/preferences.ts b/src/main/modules/preferences.ts index 618c544..0719be7 100644 --- a/src/main/modules/preferences.ts +++ b/src/main/modules/preferences.ts @@ -90,13 +90,13 @@ abstract class Preferences { static #withFreshInstallDefaults(data: PreferencesSchema): PreferencesSchema { if (!this.#freshInstall || Object.keys(data.mods).length) return data; + // fresh installs seed every mod EXPLICITLY off; a missing row falls + // back to enabled, which keeps legacy profiles untouched const mods = { ...data.mods }; for (const id of DEFAULT_ENABLED_MODS) - mods[id] = { enabled: true, installedFiles: [], ignoreUpdates: false }; + mods[id] = { enabled: false, installedFiles: [], ignoreUpdates: false }; - Logger.info( - `Fresh install: enabling ${DEFAULT_ENABLED_MODS.join(', ')} by default` - ); + Logger.info('Fresh install: all mods start disabled (opt-in)'); return { ...data, mods }; } diff --git a/src/main/modules/updater.ts b/src/main/modules/updater.ts index f67c088..dd9453c 100644 --- a/src/main/modules/updater.ts +++ b/src/main/modules/updater.ts @@ -26,6 +26,8 @@ import { stopSeeding } from '~main/modules/aria2'; +import { healRealmlist } from '~main/modules/patcher'; + import Preferences from './preferences'; import Observable from './observable'; @@ -189,10 +191,14 @@ class UpdaterClass extends Observable { } async refreshSeeding() { + // no seeding while dxvk is off: aria2 would recreate the parked + // d3d9.dll as a 0-byte stub and break the game's d3d9 import + const dxvkOff = Preferences.data.mods?.dxvk?.enabled === false; if ( Preferences.data.shareDownloads !== false && Preferences.data.clientDir && - this.status.state === 'upToDate' + this.status.state === 'upToDate' && + !dxvkOff ) await startSeeding(Preferences.data.clientDir); else stopSeeding(); @@ -209,6 +215,10 @@ class UpdaterClass extends Observable { progress: -1, message: 'Checking for updates...' }; + // an interrupted sync can leave realmlist.wtf as a 0-byte placeholder + await healRealmlist(clientPath).catch(e => + Logger.warn('realmlist heal failed', e) + ); try { const sha = await fetchTorrentSha(url); await this.#reconcileClientPatch(clientPath); @@ -359,8 +369,10 @@ class UpdaterClass extends Observable { sha: string, url: string ): Promise { - if (!Preferences.data.syncedTorrentHash) return false; if (!(await torrentTreeIntact(clientPath, url))) return false; + // adopt a complete tree even on a fresh profile; empty delta selection + // would otherwise fall through to a full re-download + await clearTorrentResumeState().catch(() => {}); await refreshPristineWow(clientPath); const removed = await pruneStaleArchives( clientPath, @@ -444,6 +456,10 @@ class UpdaterClass extends Observable { } }); if (!(await torrentTreeIntact(clientPath, url))) { + // stale resume state makes aria2 skip pieces it thinks are + // done; drop it so the next attempt starts from the real files + await clearTorrentResumeState().catch(() => undefined); + await healRealmlist(clientPath).catch(() => undefined); this.status = { state: 'updateAvailable', message: 'Download incomplete. Click update to finish.' @@ -458,6 +474,15 @@ class UpdaterClass extends Observable { ); if (removed.length) Logger.log(`Removed stale archives: ${removed.join(', ')}`); + // a clean sync restores d3d9.dll; re-park it if dxvk is off + if (Preferences.data.mods?.dxvk?.enabled === false) { + const live = path.join(clientPath, 'd3d9.dll'); + const off = path.join(clientPath, 'd3d9.dll.off'); + if (await fs.pathExists(live)) { + await fs.remove(off).catch(() => undefined); + await fs.move(live, off).catch(() => undefined); + } + } await this.#reconcileClientPatch(clientPath); await this.#reconcileRaidVisuals(clientPath); Preferences.data = { @@ -468,6 +493,7 @@ class UpdaterClass extends Observable { await this.refreshSeeding(); } catch (e) { Logger.error('Torrent update failed', e); + await healRealmlist(clientPath).catch(() => undefined); this.status = { state: 'failed', message: e instanceof Error ? e.message : 'Download failed'