OctoLauncher 1.3.5

This commit is contained in:
OctoWoW
2026-08-14 11:37:18 +00:00
parent 5dca94a3fc
commit f9d89601f1
11 changed files with 239 additions and 29 deletions
+2
View File
@@ -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
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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",
+6 -5
View File
@@ -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
);
+7
View File
@@ -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 () => {
// 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();
}
})
});
+21
View File
@@ -269,6 +269,16 @@ const torrentDataDirs = (torrentBytes: Buffer): Set<string> => {
);
};
// 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<string, number> = {
'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',
+8 -1
View File
@@ -145,8 +145,15 @@ export const detectAntivirusBlocks = async (): Promise<string[]> => {
const blocked = new Set<string>();
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 ' +
+75 -6
View File
@@ -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<ModsStatus> {
// 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<ModsStatus> {
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<ModsStatus> {
}
// 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()) {
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<ModsStatus> {
}
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<ModsStatus> {
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);
+80 -3
View File
@@ -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;
+4 -4
View File
@@ -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 };
}
+28 -2
View File
@@ -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<UpdaterStatus> {
}
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<UpdaterStatus> {
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<UpdaterStatus> {
sha: string,
url: string
): Promise<boolean> {
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<UpdaterStatus> {
}
});
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<UpdaterStatus> {
);
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<UpdaterStatus> {
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'