Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 15 additions & 7 deletions src/features/imageAssetSupport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,11 +204,24 @@ export async function getCandidateRoots(documentFsPath: string, options: Candida
return [...await promise];
}

/** Is `candidate` the extracted game-asset cache directory, or anything beneath it? */
function isInsideGameAssetCache(candidate: string): boolean {
const forCompare = (p: string) => (process.platform === 'win32' ? path.resolve(p).toLowerCase() : path.resolve(p));
const rel = path.relative(forCompare(getGameAssetCacheDir()), forCompare(candidate));
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
}

async function getCandidateRootsUncached(documentFsPath: string, options: CandidateRootOptions): Promise<string[]> {
const seen = new Set<string>();
const roots: string[] = [];
const add = (candidate: string) => {
if (candidate && !seen.has(candidate)) {
// Never let generic local resolution reach into the extracted game-asset cache. Its entries
// are namespaced by installation root and must only be read back through the game-data
// lookups that know which namespace is active — joining an asset path onto the cache dir (or
// onto one installation's bucket) would serve another installation's bytes. This guards every
// source of roots at once, including a document that itself lives inside the cache and the
// ancestor walk below.
if (candidate && !seen.has(candidate) && !isInsideGameAssetCache(candidate)) {
seen.add(candidate);
roots.push(candidate);
}
Expand Down Expand Up @@ -263,7 +276,6 @@ async function getCandidateRootsUncached(documentFsPath: string, options: Candid
}
}));

add(getGameAssetCacheDir());
return roots;
}

Expand All @@ -277,12 +289,10 @@ const IMPORT_SKIP_DIRS = new Set(['node_modules', '.git', '.svn', 'dist', 'out',
* root (map-relative, WC3 style) so they resolve and serialize correctly. Bounded to keep it cheap.
*/
export async function gatherImportedAssets(documentFsPath: string): Promise<{ model: ImportedAsset[]; icon: ImportedAsset[]; sound: ImportedAsset[] }> {
const cacheDir = getGameAssetCacheDir();
// Prefer the most specific root. A file under `imports\btn` is reachable both from the workspace
// root (`imports\btn\x.blp`) and the dedicated imports root (`btn\x.blp`); the latter is the useful
// WC3 asset path. Walking child roots first also lets the physical-path guard below keep that form.
const roots = (await getCandidateRoots(documentFsPath))
.filter((r) => r !== cacheDir)
.sort((a, b) => path.resolve(b).split(path.sep).length - path.resolve(a).split(path.sep).length);
const model: ImportedAsset[] = [];
const icon: ImportedAsset[] = [];
Expand Down Expand Up @@ -515,10 +525,8 @@ async function resolveCachedGameAsset(variant: string): Promise<string | undefin
*/
export async function resolveAssetPathWithCasc(assetPath: string, roots: readonly string[], kind: AssetKind = 'any'): Promise<string | undefined> {
const variants = assetPathVariants(assetPath, kind);
const cacheDir = getGameAssetCacheDir();
const localRoots = roots.filter((root) => root !== cacheDir);
for (const variant of variants) {
const resolved = await resolveAssetPath(variant, localRoots, kind);
const resolved = await resolveAssetPath(variant, roots, kind);
if (resolved) return resolved;
}
for (const variant of variants) {
Expand Down
139 changes: 89 additions & 50 deletions src/features/preview/cascStorage.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use strict';

import * as child_process from 'child_process';
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
Expand All @@ -21,7 +22,10 @@ type GameStorageKind = 'casc' | 'mpq';

interface GameDataRoot {
kind: GameStorageKind;
/** The installation directory as detected, and what the storage openers are given. */
root: string;
/** `root` resolved through `realpath` — the spelling the disk cache is namespaced by. */
canonicalRoot: string;
}

interface GameStorage {
Expand Down Expand Up @@ -304,6 +308,26 @@ async function computeDefaultWarcraftPaths(): Promise<string[]> {
return candidates;
}

/**
* Builds the detection result, pairing the root as discovered with the canonical spelling used to
* key its cache bucket. The same install arrives here spelled several ways — a hand-typed
* `wurst.wc3path`, an uppercase drive letter from the default-path scan, a registry value, a symlink
* — and hashing the spelling verbatim would give each one its own bucket, re-extracting the same
* install into another full copy on disk. `realpath` settles them on the filesystem's own canonical
* form without guessing at case sensitivity, falling back to the path as given if it can't be
* canonicalised. Only the cache tag uses it: `root` stays exactly as detected, since that is what
* gets handed to the native storage openers.
*/
async function makeGameDataRoot(kind: GameStorageKind, root: string): Promise<GameDataRoot> {
let canonicalRoot = root;
try {
canonicalRoot = await fs.promises.realpath(root);
} catch {
// Keep the discovered spelling; a bucket keyed on it is still correct, just not deduplicated.
}
return { kind, root, canonicalRoot };
}

/** Walk up from `startPath` until we find a WC3 CASC root (has Data/ AND .build.info or .build.db). */
async function findCascDataRoot(startPath: string): Promise<string | null> {
let dir = startPath;
Expand Down Expand Up @@ -378,25 +402,35 @@ function getCachedAssetPath(cacheDir: string, normalizedAssetPath: string): stri
return path.join(cacheDir, ...normalizedAssetPath.replace(/:/g, '$').split('\\'));
}

function getSourceCachePath(kind: GameStorageKind, normalizedAssetPath: string): string {
return getCachedAssetPath(path.join(getCacheDir(), kind), normalizedAssetPath);
/**
* Short, filesystem-safe tag distinguishing this game-data root from any other of the same storage
* kind, so switching `wurst.wc3path` between two installs of the same kind (two Reforged builds, or
* a PTR next to retail) can't serve assets that were extracted from the other one. The path is hashed
* exactly as resolved, with no case folding: two spellings that differ only in case are separate
* directories on a case-sensitive filesystem and must never share a bucket. On a case-insensitive one
* they can at worst produce two buckets for the same install, which costs a re-extraction — the safe
* direction, and not worth a filesystem-behaviour probe to avoid.
*/
function installRootTag(root: string): string {
return crypto.createHash('sha1').update(path.resolve(root)).digest('hex').slice(0, 8);
}

function getSourceCachePath(root: GameDataRoot, normalizedAssetPath: string): string {
return getCachedAssetPath(path.join(getCacheDir(), root.kind, installRootTag(root.canonicalRoot)), normalizedAssetPath);
}

export async function findCachedGameAsset(assetPath: string): Promise<string | undefined> {
const normalized = normalizeCascAssetPath(assetPath);
const root = await getGameDataRoot(defaultCascLog);
const candidates = [
root?.kind ? getSourceCachePath(root.kind, normalized) : '',
// Preserve assets extracted by older extension versions when CASC is active.
root?.kind === 'casc' ? getCachedAssetPath(getCacheDir(), normalized) : '',
].filter(Boolean);
for (const candidate of candidates) {
try {
await fs.promises.access(candidate, fs.constants.F_OK);
return candidate;
} catch {}
// No live root, no namespace to check — see the comment on readCachedGameBuffer.
if (!root) return undefined;
const candidate = getSourceCachePath(root, normalized);
try {
await fs.promises.access(candidate, fs.constants.F_OK);
return candidate;
} catch {
return undefined;
}
return undefined;
}

function rememberMiss(cache: Set<string>, key: string): void {
Expand Down Expand Up @@ -458,12 +492,12 @@ async function detectGameDataRoot(log: (msg: string) => void): Promise<GameDataR
const dataRoot = await findCascDataRoot(wc3path);
if (dataRoot) {
if (dataRoot !== wc3path) logCascRootOnce(`CASC root: ${dataRoot} (from ${wc3path})`, log);
return { kind: 'casc', root: dataRoot };
return makeGameDataRoot('casc', dataRoot);
}
const mpqRoot = await findMpqDataRoot(wc3path);
if (mpqRoot) {
logCascRootOnce(`Legacy MPQ root: ${mpqRoot} (from ${wc3path})`, log);
return { kind: 'mpq', root: mpqRoot };
return makeGameDataRoot('mpq', mpqRoot);
}
log(`CASC wurst.wc3path "${wc3path}" has no WC3 CASC root — falling back to default paths`);
channelLog(`wurst.wc3path "${wc3path}" has no WC3 CASC root (looked for Data/ + .build.info|.build.db) — falling back to default paths`);
Expand All @@ -473,12 +507,12 @@ async function detectGameDataRoot(log: (msg: string) => void): Promise<GameDataR
const dataRoot = await findCascDataRoot(p);
if (dataRoot) {
logCascRootOnce(`CASC root: ${dataRoot}`, log);
return { kind: 'casc', root: dataRoot };
return makeGameDataRoot('casc', dataRoot);
}
const mpqRoot = await findMpqDataRoot(p);
if (mpqRoot) {
logCascRootOnce(`Legacy MPQ root: ${mpqRoot}`, log);
return { kind: 'mpq', root: mpqRoot };
return makeGameDataRoot('mpq', mpqRoot);
}
}
logCascRootOnce(`CASC skip: no WC3 install found (${defaultPaths.length} default paths checked)`, log);
Expand Down Expand Up @@ -626,10 +660,8 @@ type TextureExt = 'dds' | 'blp' | 'tga';

/** Look up a texture. Checks disk cache first; if missing, extracts in-process and caches to disk. */
// eslint-disable-next-line sonarjs/cognitive-complexity -- TODO(lint-cleanup): pre-existing, tracked for a dedicated decomposition pass rather than a rushed refactor here.
export async function findCascTexture(texPath: string, log: (msg: string) => void): Promise<{ buf: Buffer; ext: TextureExt } | null> {
const cacheDir = getCacheDir();
export async function findCascTexture(texPath: string, log: (msg: string) => void): Promise<{ buf: Buffer; ext: TextureExt; cachePath: string } | null> {
const gameRoot = await getGameDataRoot(log);
const cacheKind = gameRoot?.kind ?? 'casc';
// CASC paths are lowercase with backslash separators
const basePath = textureBasePath(texPath);
const ddsPath = `${basePath}.dds`;
Expand All @@ -652,13 +684,14 @@ export async function findCascTexture(texPath: string, log: (msg: string) => voi
if (fallbackDdsPath) cacheCandidates.push([fallbackDdsPath, 'dds']);
if (fallbackBlpPath) cacheCandidates.push([fallbackBlpPath, 'blp']);
if (fallbackTgaPath) cacheCandidates.push([fallbackTgaPath, 'tga']);
for (const [rel, ext] of cacheCandidates) {
const cachePaths = [getSourceCachePath(cacheKind, rel)];
if (cacheKind === 'casc') cachePaths.push(getCachedAssetPath(cacheDir, rel));
for (const cachePath of cachePaths) {
// Disk-cache paths are namespaced by install root, so a lookup needs a live root to know which
// bucket to check — without one we can't tell which install a stale cache entry came from.
if (gameRoot) {
for (const [rel, ext] of cacheCandidates) {
const cachePath = getSourceCachePath(gameRoot, rel);
try {
const buf = await fs.promises.readFile(cachePath);
return { buf, ext };
return { buf, ext, cachePath };
} catch {}
}
}
Expand Down Expand Up @@ -687,13 +720,13 @@ export async function findCascTexture(texPath: string, log: (msg: string) => voi

for (const [gamePath, ext] of candidates) {
const rel = pathForExt(ext);
const cachePath = getSourceCachePath(gameRoot!.kind, rel);
const cachePath = getSourceCachePath(gameRoot!, rel);
const buf = await gameReadDirect(gameRoot!, gamePath, log);
if (buf) {
log(`${gameRoot!.kind.toUpperCase()} extracted: ${gamePath} (${buf.length} bytes) -> ${cachePath}`);
await fs.promises.mkdir(path.dirname(cachePath), { recursive: true });
await fs.promises.writeFile(cachePath, buf);
return { buf, ext };
return { buf, ext, cachePath };
}
}

Expand All @@ -707,11 +740,11 @@ export async function findCascTexture(texPath: string, log: (msg: string) => voi
const buf = await gameReadDirect(gameRoot!, found, log);
if (!buf) continue;
const rel = pathForExt(ext);
const cachePath = getSourceCachePath(gameRoot!.kind, rel);
const cachePath = getSourceCachePath(gameRoot!, rel);
log(`${gameRoot!.kind.toUpperCase()} basename-resolved texture: ${baseNoExt}.${ext} -> ${found} (${buf.length} bytes)`);
await fs.promises.mkdir(path.dirname(cachePath), { recursive: true });
await fs.promises.writeFile(cachePath, buf);
return { buf, ext };
return { buf, ext, cachePath };
}
}
rememberMiss(cascTextureMissCache, missKey);
Expand All @@ -721,16 +754,17 @@ export async function findCascTexture(texPath: string, log: (msg: string) => voi

export const findGameTexture = findCascTexture;

async function readCachedGameBuffer(assetPath: string, root: GameDataRoot | null): Promise<Buffer | null> {
async function readCachedGameBuffer(assetPath: string, root: GameDataRoot | null): Promise<{ buf: Buffer; cachePath: string } | null> {
// Namespaced cache paths need a known install root — without a live one we can't tell whose
// cache we would be reading, so there is nothing safe to check.
if (!root) return null;
const normalized = normalizeCascAssetPath(assetPath);
const cachePaths = [getSourceCachePath(root?.kind ?? 'casc', normalized)];
if (root?.kind === 'casc') cachePaths.push(getCachedAssetPath(getCacheDir(), normalized));
for (const cachePath of cachePaths) {
try {
return await fs.promises.readFile(cachePath);
} catch {}
const cachePath = getSourceCachePath(root, normalized);
try {
return { buf: await fs.promises.readFile(cachePath), cachePath };
} catch {
return null;
}
return null;
}

function gameAssetCandidates(root: GameDataRoot, normalized: string): string[] {
Expand Down Expand Up @@ -762,13 +796,19 @@ function gameAssetCandidates(root: GameDataRoot, normalized: string): string[] {
}

async function writeGameCache(root: GameDataRoot, assetPath: string, data: Buffer): Promise<string> {
const cachePath = getSourceCachePath(root.kind, assetPath);
const cachePath = getSourceCachePath(root, assetPath);
await fs.promises.mkdir(path.dirname(cachePath), { recursive: true });
await fs.promises.writeFile(cachePath, data);
return cachePath;
}

export async function findCascAsset(assetPath: string, log: (msg: string) => void): Promise<Buffer | null> {
/**
* Returns the cache path alongside the buffer so callers that need the on-disk location (e.g.
* `ensureCascAssetCached`) use exactly the path this lookup actually read from or wrote to, instead
* of re-deriving it from the live game-data root afterwards — which can have moved on if
* `wurst.wc3path` changed in between.
*/
async function findCascAssetWithPath(assetPath: string, log: (msg: string) => void): Promise<{ buf: Buffer; cachePath: string } | null> {
const normalized = normalizeCascAssetPath(assetPath);
const gameRoot = await getGameDataRoot(log);
const cached = await readCachedGameBuffer(normalized, gameRoot);
Expand All @@ -786,7 +826,7 @@ export async function findCascAsset(assetPath: string, log: (msg: string) => voi
if (buf) {
const cachePath = await writeGameCache(gameRoot, normalized, buf);
log(`${gameRoot.kind.toUpperCase()} extracted: ${gamePath} (${buf.length} bytes) -> ${cachePath}`);
return buf;
return { buf, cachePath };
}
}

Expand All @@ -800,8 +840,8 @@ export async function findCascAsset(assetPath: string, log: (msg: string) => voi
const buf = await gameReadDirect(gameRoot, found, log);
if (buf) {
log(`${gameRoot.kind.toUpperCase()} basename-resolved: ${basename} -> ${found} (${buf.length} bytes)`);
await writeGameCache(gameRoot, normalized, buf);
return buf;
const cachePath = await writeGameCache(gameRoot, normalized, buf);
return { buf, cachePath };
}
}
}
Expand All @@ -811,6 +851,10 @@ export async function findCascAsset(assetPath: string, log: (msg: string) => voi
return null;
}

export async function findCascAsset(assetPath: string, log: (msg: string) => void): Promise<Buffer | null> {
return (await findCascAssetWithPath(assetPath, log))?.buf ?? null;
}

export const findGameAsset = findCascAsset;

export async function listGameAssetPaths(
Expand Down Expand Up @@ -872,19 +916,14 @@ export function logGameData(message: string): void {
*/
export async function ensureCascCached(assetPath: string): Promise<string | undefined> {
const result = await findCascTexture(assetPath, defaultCascLog);
if (!result) return undefined;
const rel = `${textureBasePath(assetPath)}.${result.ext}`;
const kind = (await getGameDataRoot(defaultCascLog))?.kind ?? 'casc';
return getSourceCachePath(kind, rel);
return result?.cachePath;
}

export const ensureGameTextureCached = ensureCascCached;

export async function ensureCascAssetCached(assetPath: string): Promise<string | undefined> {
const result = await findCascAsset(assetPath, defaultCascLog);
if (!result) return undefined;
const kind = (await getGameDataRoot(defaultCascLog))?.kind ?? 'casc';
return getSourceCachePath(kind, normalizeCascAssetPath(assetPath));
const result = await findCascAssetWithPath(assetPath, defaultCascLog);
return result?.cachePath;
}

export const ensureGameAssetCached = ensureCascAssetCached;
Expand Down
Loading