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
47 changes: 47 additions & 0 deletions scripts/test-webview.js
Original file line number Diff line number Diff line change
Expand Up @@ -1442,6 +1442,52 @@ function testMpqReextractUsesFreshUriAfterDeletedOutput() {
}
}


async function testGameFileAliases() {
const load = createTsLoader({
mocks: { vscode: {}, 'casc-ts': {}, 'src/features/diagnostics.ts': {} },
augment: { 'src/features/preview/cascStorage.ts': 'export { CascGameStorage };' },
});
const { CascGameStorage } = load('src/features/preview/cascStorage.ts');
const table = [
{ src: 'Textures/GenericGlow2_mip1.blp', dest: 'Textures/GenericGlow2.blp', assetType: 'Texture' },
{ src: 'Models/old.mdx', dest: 'Models/new.mdx', assetType: 'Model' },
{ src: 'Models/chain.mdx', dest: 'Models/old.mdx', assetType: 'Model' },
{ src: 'Models/cycle-a.mdx', dest: 'Models/cycle-b.mdx' },
{ src: 'Models/cycle-b.mdx', dest: 'Models/cycle-a.mdx' },
{ src: 3, dest: null },
];
const files = new Map([
['war3.w3mod:filealiases.json', Buffer.from(JSON.stringify(table))],
['war3.w3mod:textures/genericglow2.dds', Buffer.from('glow')],
['war3.w3mod:_hd.w3mod:textures/genericglow2.dds', Buffer.from('hd glow')],
['war3.w3mod:models/new.mdx', Buffer.from('model')],
]);
let tableReads = 0;
const storage = {
hasFileAsync: async p => files.has(p),
readFileAsync: async p => {
if (p.endsWith('filealiases.json')) tableReads++;
if (!files.has(p)) throw new Error('missing ' + p);
return files.get(p);
},
};
const adapter = new CascGameStorage(storage);
const alias = 'war3.w3mod:Textures\\GenericGlow2_mip1.dds';
assert.equal(await adapter.hasFileAsync(alias), true);
assert.equal((await adapter.readFileAsync(alias)).toString(), 'glow');
assert.equal((await adapter.readFileAsync('war3.w3mod:_hd.w3mod:Textures/GenericGlow2_mip1.dds')).toString(), 'hd glow');
assert.equal((await adapter.readFileAsync('war3.w3mod:Models/chain.mdx')).toString(), 'model');
assert.equal(await adapter.hasFileAsync('war3.w3mod:Models/cycle-a.mdx'), false);
assert.equal(await adapter.hasFileAsync('war3.w3mod:Textures/Unknown_mip1.dds'), false, 'suffix alone never implies an alias');
await new CascGameStorage(storage).hasFileAsync(alias);
assert.equal(tableReads, 1, 'the installation alias table is shared across adapter instances');
files.set('war3.w3mod:textures/genericglow2_mip1.dds', Buffer.from('original'));
assert.equal((await adapter.readFileAsync(alias)).toString(), 'original', 'physical requested files win');
const legacy = new CascGameStorage({hasFileAsync: async () => false});
assert.equal(await legacy.hasFileAsync(alias), false, 'installations without alias tables stay supported');
}

async function main() {
const { expandModelVariants } = loadTsModule('src/features/preview/modelVariants.ts');
const stem = 'Doodads\\Undercity\\Props\\AlchemySet\\AlchemySet';
Expand All @@ -1457,6 +1503,7 @@ async function main() {
'expanded variants preserve the owning rawcode for asset searches');
assert.strictEqual(expandModelVariants(options, [...actualPaths, `${stem}.mdx`])[0].value,
`${stem}.mdl`, 'a real unsuffixed model is retained alongside its variants');
await testGameFileAliases();
testAssetPathNormalization();
testSignals();
testObjModTreeSelectionStaysUntracked();
Expand Down
68 changes: 64 additions & 4 deletions src/features/preview/cascStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,72 @@ class CascGameStorage implements GameStorage {

constructor(private readonly storage: CascStorage) {}

// Adapters are short-lived; cache the table on the underlying installation storage.
private static readonly aliases = new WeakMap<CascStorage, Promise<Map<string, string>>>();

private getAliases(): Promise<Map<string, string>> {
let pending = CascGameStorage.aliases.get(this.storage);
if (!pending) {
pending = this.readAliases();
CascGameStorage.aliases.set(this.storage, pending);
}
return pending;
}

private async readAliases(): Promise<Map<string, string>> {
const aliases = new Map<string, string>();
const aliasPath = 'war3.w3mod:filealiases.json';
if (!await this.storage.hasFileAsync(aliasPath)) return aliases;
try {
const entries: unknown = JSON.parse((await this.storage.readFileAsync(aliasPath)).toString('utf8').replace(/^\uFEFF/, ''));
if (!Array.isArray(entries)) return aliases;
for (const entry of entries) {
if (typeof entry?.src !== 'string' || typeof entry?.dest !== 'string') continue;
const src = normalizeCascAssetPath(entry.src);
const dest = normalizeCascAssetPath(entry.dest);
aliases.set(src, dest);
}
// The alias table uses legacy BLP names, while Reforged stores textures as DDS.
for (const entry of entries) {
if (entry?.assetType !== 'Texture' || typeof entry.src !== 'string' || typeof entry.dest !== 'string') continue;
const src = textureBasePath(entry.src);
const dest = textureBasePath(entry.dest);
for (const ext of ['dds', 'blp', 'tga']) {
const key = src + '.' + ext;
if (!aliases.has(key)) aliases.set(key, dest + '.' + ext);
}
}
} catch (error) {
channelLog('CASC file aliases unavailable: ' + formatDiagnosticError(error));
}
return aliases;
}

private async resolveFilePath(filePath: string): Promise<string> {
const normalized = normalizeCascAssetPath(filePath);
const boundary = normalized.lastIndexOf(':') + 1;
const prefix = normalized.slice(0, boundary);
let relative = normalized.slice(boundary);
const seen = new Set<string>();
while (!seen.has(relative)) {
seen.add(relative);
const candidate = (prefix + relative).replace(/\\/g, '/');
// A real file at the requested path takes precedence over its alias.
if (await this.storage.hasFileAsync(candidate)) return candidate;
const target = (await this.getAliases()).get(relative);
if (!target) break;
relative = target;
}
return normalized.replace(/\\/g, '/');
}


get fileCount(): number { return this.storage.fileCount; }
readFileAsync(filePath: string): Promise<Buffer> {
return this.storage.readFileAsync(normalizeCascAssetPath(filePath).replace(/\\/g, '/'));
async readFileAsync(filePath: string): Promise<Buffer> {
return this.storage.readFileAsync(await this.resolveFilePath(filePath));
}
hasFileAsync(filePath: string): Promise<boolean> {
return this.storage.hasFileAsync(normalizeCascAssetPath(filePath).replace(/\\/g, '/'));
async hasFileAsync(filePath: string): Promise<boolean> {
return this.storage.hasFileAsync(await this.resolveFilePath(filePath));
}
listFiles(): Promise<string[]> {
return Promise.resolve(this.storage.listFiles().map(normalizeGameAssetSeparators));
Expand Down
Loading