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
10 changes: 5 additions & 5 deletions electron/main/services/heroAnimationMetadata.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { promises as fs } from 'node:fs';
import { join, posix } from 'node:path';
import { tmpdir } from 'node:os';
import { attachmentMetadataResources } from './modelAttachments';
import { attachmentMetadataResources, mapConcurrent } from './modelAttachments';
import { readVpkEntryBytes } from './vpk';
import { runVpkmergeStdout } from './modMerger';
import type { HeroAnimationInfo } from '../../../src/lib/heroAnimationCatalog';
Expand Down Expand Up @@ -60,8 +60,8 @@ export async function readHeroAnimationMetadata(vpk: string, base: string, entry
if (!/^models\/heroes[^/]*\/[a-zA-Z0-9_./-]+\.vmdl_c$/.test(entry) || entry.includes('..')) return metadata;
const dir = await fs.mkdtemp(join(tmpdir(), 'grimoire-animation-metadata-'));
try {
for (const [index, clip] of clips.slice(0, 8).entries()) {
if (!/^[a-zA-Z0-9_-]+$/.test(clip.name)) continue;
await mapConcurrent(clips.slice(0, 8), 8, async (clip, index) => {
if (!/^[a-zA-Z0-9_-]+$/.test(clip.name)) return;
for (const path of heroAnimationMetadataPaths(entry, clip.name)) {
const bytes = readVpkEntryBytes(vpk, path) ?? (vpk !== base ? readVpkEntryBytes(base, path) : null);
if (!bytes || bytes.length > 2 * 1024 * 1024) continue;
Expand All @@ -72,13 +72,13 @@ export async function readHeroAnimationMetadata(vpk: string, base: string, entry
try {
const raw: unknown = JSON.parse(await runVpkmergeStdout(['soundevents', file]));
const flags = parseHeroAnimationMetadata(raw, clip);
if (flags) { metadata.set(clip.name, flags); break; }
if (flags) { metadata.set(clip.name, flags); return; }
} catch {
// Older exporters may not decode this graph generation. The reviewed
// catalog remains the fallback; missing flags are not invented.
}
}
}
});
} finally { await fs.rm(dir, { recursive: true, force: true }); }
return metadata;
}
3 changes: 1 addition & 2 deletions electron/main/services/heroParticleExport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os';
import { afterEach, describe, expect, it, vi } from 'vitest';
const h = vi.hoisted(() => ({ run: vi.fn(), stdout: vi.fn(), index: vi.fn(), attachments: vi.fn() }));
vi.mock('./modMerger', () => ({ runVpkmerge: h.run, runVpkmergeStdout: h.stdout }));
vi.mock('./vpk', () => ({ parseVpkDirectoryCached: h.index }));
vi.mock('./vpk', () => ({ parseVpkDirectoryCached: h.index, vpkHasEntry: (path: string, entry: string) => (h.index(path) as string[] | null)?.includes(entry) ?? false }));
vi.mock('./modelAttachments', () => ({ exportModelAttachments: h.attachments }));
import { exportParticleBundle, particleDescriptor } from './heroParticleExport';
const resource = (overrides = {}) => ({ _class: 'CParticleSystemDefinition', m_Renderers: [], ...overrides });
Expand Down Expand Up @@ -87,7 +87,6 @@ describe('compiled particle export', () => {
for (const [name, owner] of [['override', 'skin.vpk'], ['base', 'base.vpk'], ['core', 'core.vpk']]) {
expect(await fs.readFile(join(dir, 'tex', `materials_particle_${name}_vtex.png`), 'utf8')).toBe(owner);
}
expect(h.index).toHaveBeenCalledTimes(3);
expect(h.run).toHaveBeenCalledTimes(3);
expect(h.stdout.mock.calls[0][0]).toContain('base.vpk');
expect(await fs.readdir(join(dir, 'tex'))).toHaveLength(3);
Expand Down
8 changes: 4 additions & 4 deletions electron/main/services/heroParticleExport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { FxControlPoint, FxDescriptor, FxNode, FxRenderer } from '../../../
import { fxTexturePngName } from '../../../src/components/locker/fxDescriptor';
import { runVpkmerge, runVpkmergeStdout } from './modMerger';
import { readParticleSheet } from './particleSheet';
import { parseVpkDirectoryCached, readVpkEntryBytes } from './vpk';
import { readVpkEntryBytes, vpkHasEntry } from './vpk';
import { readParticleSnapshot } from './particleSnapshot';
import { exportModelAttachments } from './modelAttachments';

Expand Down Expand Up @@ -95,7 +95,7 @@ export async function exportParticleBundle(pak: string, entry: string, descripto
if (typeof snapshot === 'string') {
if (!/^particles\/[a-zA-Z0-9_./-]+\.vsnap$/.test(snapshot) || snapshot.includes('..')) throw new Error('Invalid particle snapshot path.');
const entry = `${snapshot}_c`;
const owner = [...new Set([...texturePaks, pak])].find((p) => parseVpkDirectoryCached(p)?.includes(entry));
const owner = [...new Set([...texturePaks, pak])].find((p) => vpkHasEntry(p, entry));
const bytes = owner ? readVpkEntryBytes(owner, entry) : null;
if (!bytes || bytes.length > 4*1024*1024) throw new Error('Particle snapshot is unavailable or exceeds preview limits.');
const metadata: unknown = JSON.parse(await runVpkmergeStdout(['soundevents', entry, '--from-vpk', owner!]));
Expand Down Expand Up @@ -160,9 +160,9 @@ export async function exportParticleBundle(pak: string, entry: string, descripto
const owners = new Map<string, string[]>();
// Caller supplies mounted priority: selected skin stack, Citadel, core.
// Exact entries avoid decoding the same texture from a lower-priority VPK.
const indexes = texturePaks.length ? [...new Set(texturePaks)].map((path) => ({ path, entries: new Set(parseVpkDirectoryCached(path) ?? []) })) : null;
const mounted = texturePaks.length ? [...new Set(texturePaks)] : null;
for (const texture of textures) {
const owner = indexes ? indexes.find(({ entries }) => entries.has(`${texture}_c`))?.path : pak;
const owner = mounted ? mounted.find((path) => vpkHasEntry(path, `${texture}_c`)) : pak;
if (!owner) throw new Error(`Particle texture is absent from mounted packages: ${texture}`);
const group = owners.get(owner) ?? [];
group.push(texture);
Expand Down
40 changes: 38 additions & 2 deletions electron/main/services/heroPoseModels.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ vi.mock('./modMerger', () => ({
runVpkmerge: h.run, runVpkmergeStdout: h.stdout, verifyVpkOutput: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('./heroParticleExport', () => ({ exportParticleBundle: h.particles }));
vi.mock('./heroPortraits', () => ({ codenamesForHero: (name: string) => name === 'Seven' ? ['gigawatt'] : ['yamato'] }));
vi.mock('./heroPortraits', () => ({
codenamesForHero: (name: string) => name === 'Seven' ? ['gigawatt'] : name === 'Twin' ? ['twin_a', 'twin_b'] : ['yamato'],
}));
vi.mock('./deadlock', () => ({
getCitadelPath: (path: string) => join(path, 'game', 'citadel'),
getAddonsPath: (path: string) => join(path, 'game', 'citadel', 'addons'),
Expand Down Expand Up @@ -139,7 +141,8 @@ describe('rigged preview physics bundle', () => {
expect(info.key).toContain('::one_dir.vpk::');
const cloth = JSON.parse(await fs.readFile(join(cacheDir(info.key), 'cloth-rigged.json'), 'utf8'));
expect(cloth.source).toBe(join(addons, 'one_dir.vpk'));
expect(h.stdout.mock.calls.filter(([args]) => args[1] === 'femodel')).toHaveLength(1);
const physics = h.stdout.mock.calls.filter(([args]) => args[1] === 'femodel');
expect(argument(physics.at(-1)![0], '--vpk')).toBe(join(addons, 'one_dir.vpk'));
});

it('retains animation with an explicit null sidecar when physics extraction fails', async () => {
Expand All @@ -166,6 +169,39 @@ describe('rigged preview physics bundle', () => {
expect((await getRiggedHeroPose(game(), 'Yamato')).hasModel).toBe(false);
});

it('records a clipless rigged export once and skips re-exporting it', async () => {
const json = Buffer.from(JSON.stringify({ asset: { version: '2.0' }, animations: [] }).padEnd(48, ' '));
const glb = Buffer.alloc(20 + json.length);
glb.writeUInt32LE(0x46546c67, 0); glb.writeUInt32LE(2, 4); glb.writeUInt32LE(glb.length, 8);
glb.writeUInt32LE(json.length, 12); glb.writeUInt32LE(0x4e4f534a, 16); json.copy(glb, 20);
h.run.mockImplementation(async (args) => fs.writeFile(argument(args, '--out'), glb));
expect((await exportRiggedHeroPose(game(), 'Yamato')).hasModel).toBe(false);
expect(await fs.readFile(join(cacheDir((await getRiggedHeroPose(game(), 'Yamato')).key), 'cloth-rigged.json'), 'utf8').catch(() => null)).toBeNull();
const spawns = h.run.mock.calls.length + h.stdout.mock.calls.length;
expect((await getRiggedHeroPose(game(), 'Yamato')).hasModel).toBe(false);
expect((await exportRiggedHeroPose(game(), 'Yamato')).hasModel).toBe(false);
expect(h.run.mock.calls.length + h.stdout.mock.calls.length).toBe(spawns);
});

it('shares one rigged export between concurrent requests for the same stack', async () => {
const [first, second] = await Promise.all([exportRiggedHeroPose(game(), 'Yamato'), exportRiggedHeroPose(game(), 'Yamato')]);
expect(second).toEqual(first);
expect(h.run.mock.calls.filter(([args]) => args[0] === 'model' && args[1] === 'export')).toHaveLength(1);
});

it('does not record a clipless marker when another selector failed to list clips', async () => {
h.stdout.mockImplementation(async (args) => {
if (args[1] !== 'clips') return 'null';
if (argument(args, '--hero') === 'twin_a') throw new Error('transient read failure');
return '[]';
});
expect((await exportRiggedHeroPose(game(), 'Twin')).hasModel).toBe(false);
const listings = () => h.stdout.mock.calls.filter(([args]) => args[1] === 'clips').length;
const before = listings();
expect((await exportRiggedHeroPose(game(), 'Twin')).hasModel).toBe(false);
expect(listings()).toBeGreaterThan(before);
});

it('preserves a current rigged-only cache when sweeping old static entries', async () => {
const info = await exportRiggedHeroPose(game(), 'Yamato');
await sweepHeroPoseCache();
Expand Down
112 changes: 91 additions & 21 deletions electron/main/services/heroPoseModels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,15 +289,29 @@ function riggedClothFile(key: string): string {
* Pre-v14 Infernus GLBs were baked via `--hero inferno`, which read the base pak
* and so cached the vanilla look over any active skin; force a re-export.
*
* v16: textures are baked at PREVIEW_MAX_TEXTURE (`--max-texture`). Pre-v16 GLBs
* carry full-size 2048/4096 textures (35-70 MB) that load several times slower.
*
* The Source 2 extras schema version (SOURCE2_EXTRAS_VERSION) is folded into the
* effective key below, so a material-extras schema bump auto-busts this cache
* with no manual edit here, and the cache version cannot drift from the parser's
* expected schema. Bump POSE_PIPELINE_VERSION only for export changes unrelated
* to the extras schema (model resolution, index offsets, ...).
*/
const POSE_PIPELINE_VERSION = '15';
const POSE_PIPELINE_VERSION = '16';
const POSE_CACHE_VERSION = `${POSE_PIPELINE_VERSION}.x${SOURCE2_EXTRAS_VERSION}`;

/**
* Longest texture edge baked into preview GLBs (`model export --max-texture`):
* each texture embeds at its largest mip that fits. Full-size hero textures
* (2048/4096) are far more than the Locker viewport shows, and they dominate
* export time, GLB size, and the renderer's decode + GPU upload. 1024 cuts a
* hero GLB ~3x (drifter skin: 39 -> 14 MB). Raising it to 2048 keeps fine print
* sharp at the closest zoom but costs ~300 MB more renderer memory per loaded
* hero (decoded ImageBitmaps; see loadGltfPreview).
*/
const PREVIEW_MAX_TEXTURE = '1024';

const POSE_VERSION_FILENAME = '.cache-version';

function versionFile(key: string): string {
Expand Down Expand Up @@ -334,11 +348,13 @@ function versionFile(key: string): string {
* v9: export a bounded menu of representative full-body motions.
* v10: preserve supported authored attachment frames beside the rigged model.
*
* v15: textures baked at PREVIEW_MAX_TEXTURE (same as POSE_CACHE_VERSION v16).
*
* Folds in SOURCE2_EXTRAS_VERSION on the same principle as POSE_CACHE_VERSION.
*/
// v14: Graves uses the complete standing weapon pose; shop parks its spectral
// hand at the model origin. Refresh existing menus instead of reusing that pose.
const RIGGED_PIPELINE_VERSION = '14';
const RIGGED_PIPELINE_VERSION = '15';
const RIGGED_CACHE_VERSION = `${RIGGED_PIPELINE_VERSION}.x${SOURCE2_EXTRAS_VERSION}`;

const RIGGED_VERSION_FILENAME = '.rigged-cache-version';
Expand All @@ -347,6 +363,39 @@ function riggedVersionFile(key: string): string {
return join(modelDir(key), RIGGED_VERSION_FILENAME);
}

// Some skins list clips that the exporter then drops, leaving a rigged GLB with
// no animation. The viewer can only discover that after a full GLB load, so
// record it once and let the preview go straight to the static pose.
const RIGGED_NO_CLIPS_FILENAME = '.rigged-no-clips';

function riggedNoClipsFile(key: string): string {
return join(modelDir(key), RIGGED_NO_CLIPS_FILENAME);
}

async function hasNoRiggedClips(key: string): Promise<boolean> {
const marker = await fs.readFile(riggedNoClipsFile(key), 'utf8').catch(() => '');
return marker.trim() === RIGGED_CACHE_VERSION;
}

/** Animation count from a GLB's JSON chunk, without reading the binary chunk.
* Null when the file is not a readable GLB, so only a proven zero is recorded. */
async function glbAnimationCount(file: string): Promise<number | null> {
const handle = await fs.open(file, 'r');
try {
const header = Buffer.alloc(20);
const { bytesRead } = await handle.read(header, 0, 20, 0);
if (bytesRead < 20 || header.readUInt32LE(0) !== 0x46546c67 || header.readUInt32LE(16) !== 0x4e4f534a) return null;
const json = Buffer.alloc(header.readUInt32LE(12));
await handle.read(json, 0, json.length, 20);
const parsed = JSON.parse(json.toString('utf8')) as { animations?: unknown[] };
return Array.isArray(parsed.animations) ? parsed.animations.length : 0;
} catch {
return null;
} finally {
await handle.close();
}
}

/**
* Curated per-hero ambient idle effect (`.vpcf_c`), keyed by display name. The
* effects-preview axis is a hand-validated roster, NOT auto-discovered (the raw
Expand Down Expand Up @@ -778,6 +827,7 @@ export async function getHeroPoseInfo(

async function infoForRiggedKey(key: string): Promise<HeroPoseInfo> {
try {
if (await hasNoRiggedClips(key)) return { hasModel: false, mtimeMs: null, key };
const stat = await fs.stat(riggedModelFile(key));
const version = await fs.readFile(riggedVersionFile(key), 'utf8').catch(() => '');
if (version.trim() !== RIGGED_CACHE_VERSION) {
Expand Down Expand Up @@ -899,6 +949,8 @@ async function runHeroPoseExportForSources(
// (Apollo, Billy, Celeste, Mina, Paige, Rem) errors here and the
// Locker falls back to the 2D portrait instead of an unposed model.
'--require-pose',
'--max-texture',
PREVIEW_MAX_TEXTURE,
'--out',
out,
]);
Expand Down Expand Up @@ -941,7 +993,13 @@ export async function exportRiggedHeroPose(
const existing = inFlightRiggedExports.get(requestKey);
if (existing) return existing;

const work = runRiggedHeroExport(deadlockPath, heroName, normalized, fallbackSkinMetaKey);
// Registered before the first await, so a concurrent request for the same
// stack joins this run instead of exporting into the same files.
const work = (async (): Promise<HeroPoseInfo> => {
const cachedKey = await resolvePoseKey(deadlockPath, heroName, normalized);
if (await hasNoRiggedClips(cachedKey)) return { hasModel: false, mtimeMs: null, key: cachedKey };
return runRiggedHeroExport(deadlockPath, heroName, normalized, fallbackSkinMetaKey);
})();
inFlightRiggedExports.set(requestKey, work);
try {
const info = await work;
Expand Down Expand Up @@ -1005,6 +1063,21 @@ async function runRiggedHeroExportForSources(
if (!clips.length) continue;
foundUsableClip = true;

// Physics and attachments read the model entry, not the exported GLB,
// so they decode while the export runs instead of after it.
const cloth = runVpkmergeStdout(['model', 'femodel', '--vpk', source.vpk, ...selector, '--base', pak01])
.then((json): unknown => JSON.parse(json))
.catch((error: unknown) => {
console.warn('[heroPoseModels] rigged physics unavailable:', heroName, error);
return null;
});
const entryIndex = selector.indexOf('--entry');
const attachments = entryIndex >= 0 && selector[entryIndex + 1]
? exportModelAttachments(source.vpk, pak01, selector[entryIndex + 1]).catch((error: unknown) => {
console.warn('[heroPoseModels] rigged attachments unavailable:', heroName, error);
return [];
})
: Promise.resolve([]);
try {
await fs.rm(riggedVersionFile(key), { force: true });
await runVpkmerge([
Expand All @@ -1017,35 +1090,32 @@ async function runRiggedHeroExportForSources(
pak01,
// The viewer plays one action at a time from this menu.
...clips.flatMap((clip) => ['--clip', clip.name]),
'--max-texture',
PREVIEW_MAX_TEXTURE,
'--out',
out,
]);
let cloth: unknown = null;
try {
cloth = JSON.parse(await runVpkmergeStdout([
'model', 'femodel', '--vpk', source.vpk, ...selector, '--base', pak01,
]));
} catch (error) {
console.warn('[heroPoseModels] rigged physics unavailable:', heroName, error);
}
await fs.writeFile(riggedClothFile(key), JSON.stringify(cloth));
let attachments: Awaited<ReturnType<typeof exportModelAttachments>> = [];
const entryIndex = selector.indexOf('--entry');
if (entryIndex >= 0 && selector[entryIndex + 1]) {
try {
attachments = await exportModelAttachments(source.vpk, pak01, selector[entryIndex + 1]);
} catch (error) {
console.warn('[heroPoseModels] rigged attachments unavailable:', heroName, error);
}
if (await glbAnimationCount(out) === 0) {
await fs.writeFile(riggedNoClipsFile(key), RIGGED_CACHE_VERSION);
return { hasModel: false, mtimeMs: null, key };
}
await fs.writeFile(join(dir, RIGGED_ATTACHMENTS_FILENAME), JSON.stringify(attachments));
await fs.rm(riggedNoClipsFile(key), { force: true });
await fs.writeFile(riggedClothFile(key), JSON.stringify(await cloth));
await fs.writeFile(join(dir, RIGGED_ATTACHMENTS_FILENAME), JSON.stringify(await attachments));
await fs.writeFile(riggedVersionFile(key), RIGGED_CACHE_VERSION);
return infoForRiggedKey(key);
} catch (err) {
lastError = err;
} finally {
// Both read source.vpk, which the outer finally deletes when it is a
// merged stack.
await Promise.all([cloth, attachments]);
}
}
if (listedAny && !foundUsableClip) {
// A selector that failed to list may still carry clips; remember
// "nothing usable" only when every selector answered.
if (lastError === undefined) await fs.writeFile(riggedNoClipsFile(key), RIGGED_CACHE_VERSION);
return { hasModel: false, mtimeMs: null, key };
}
throw lastError instanceof Error
Expand Down
Loading
Loading