@aeonsparx/pi-unreal 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +202 -2
- package/docs/internals.md +184 -0
- package/extensions/unreal/index.ts +682 -0
- package/extensions/unreal/lib/cache.ts +190 -0
- package/extensions/unreal/lib/compiledb.ts +491 -0
- package/extensions/unreal/lib/descriptors.ts +631 -0
- package/extensions/unreal/lib/engine-resolver.ts +443 -0
- package/extensions/unreal/lib/pi-tools.ts +402 -0
- package/extensions/unreal/lib/plugin-resolver.ts +305 -0
- package/extensions/unreal/lib/query.ts +272 -0
- package/extensions/unreal/lib/registry.ts +208 -0
- package/extensions/unreal/lib/scan.ts +510 -0
- package/extensions/unreal/lib/store.ts +900 -0
- package/extensions/unreal/lib/tools.ts +614 -0
- package/extensions/unreal/lib/ubt.ts +373 -0
- package/extensions/unreal/lib/util.ts +210 -0
- package/package.json +59 -4
- package/prompts/unreal-context.md +26 -0
- package/skills/ue-engine-resolution/SKILL.md +96 -0
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Engine association resolution for a `.uproject`.
|
|
3
|
+
*
|
|
4
|
+
* The algorithm mirrors the engine's own so a tool answer matches what UnrealVersionSelector
|
|
5
|
+
* and UnrealBuildTool will do:
|
|
6
|
+
*
|
|
7
|
+
* Engine/Source/Developer/DesktopPlatform/Private/DesktopPlatformBase.cpp:459-511
|
|
8
|
+
* FDesktopPlatformBase::GetEngineIdentifierForProject()
|
|
9
|
+
* - `EngineAssociation` containing `/` or `\` is a *path to the engine root*, resolved
|
|
10
|
+
* against the project directory (source builds, per-project engine checkouts).
|
|
11
|
+
* - empty `EngineAssociation` (the normal state for source builds; the engine blanks it
|
|
12
|
+
* in `SetEngineIdentifierForProject`) means "walk up from the project directory".
|
|
13
|
+
* Engine/Source/Developer/DesktopPlatform/Private/DesktopPlatformBase.cpp:401-421
|
|
14
|
+
* FDesktopPlatformBase::IsValidRootDirectory() — a directory is an engine root iff it has
|
|
15
|
+
* both `Engine/Binaries` and `Engine/Build`.
|
|
16
|
+
* Engine/Source/Developer/DesktopPlatform/Private/DesktopPlatformWindows.cpp:335-380,503
|
|
17
|
+
* install enumeration from `HKLM\SOFTWARE\EpicGames\Unreal Engine\<version>`,
|
|
18
|
+
* `HKLM\SOFTWARE\EpicGames\Unreal Engine` default value, and
|
|
19
|
+
* `HKCU\SOFTWARE\Epic Games\Unreal Engine\Builds` (GUID -> engine root).
|
|
20
|
+
* Engine/Source/Developer/DesktopPlatform/Private/DesktopPlatformBase.cpp:72-86,1573-1615
|
|
21
|
+
* `%PROGRAMDATA%\UnrealEngineLauncher\LauncherInstalled.dat` (`InstallationList[].AppName`
|
|
22
|
+
* `UE_5.8` maps to association `5.8` by stripping the `UE_` prefix).
|
|
23
|
+
*
|
|
24
|
+
* Two deliberate deviations from Epic's code:
|
|
25
|
+
* 1. `GetEngineIdentifierFromRootDir()` falls back to `RegisterEngineInstallation()`, which
|
|
26
|
+
* *writes* `HKCU\SOFTWARE\Epic Games\Unreal Engine\Builds`. This module is read-only.
|
|
27
|
+
* 2. A registry hit is never trusted on its own: the directory must exist, pass
|
|
28
|
+
* `IsValidRootDirectory`, and agree with `Engine/Build/Build.version`. Stale keys are a
|
|
29
|
+
* real failure mode (this machine has `HKLM` entries for 5.5/5.6 whose directories do not
|
|
30
|
+
* exist, and a `UE_5.8` directory with no registry entry at all).
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { readBuildVersion, parseUproject, versionToMajorMinor, type EngineVersion } from "./descriptors.ts";
|
|
34
|
+
import { noRegistry, type RegistryReader, type RegistrySnapshot } from "./registry.ts";
|
|
35
|
+
import { ancestors, isAbsolutePath, joinPath, normalizePath, parentDir, realFs, walkUp, type FsAdapter } from "./util.ts";
|
|
36
|
+
|
|
37
|
+
export type SourceKind =
|
|
38
|
+
| "user-hint"
|
|
39
|
+
| "uproject-inline-path"
|
|
40
|
+
| "launcher-dat"
|
|
41
|
+
| "hklm-version"
|
|
42
|
+
| "hkcu-builds"
|
|
43
|
+
| "hklm-installdir"
|
|
44
|
+
| "env"
|
|
45
|
+
| "dir-scan"
|
|
46
|
+
| "walk-up";
|
|
47
|
+
|
|
48
|
+
export type AssociationKind = "inline-path" | "guid" | "version" | "empty" | "legacy" | "unknown";
|
|
49
|
+
|
|
50
|
+
export type ResolutionStatus = "resolved" | "ambiguous" | "unresolved";
|
|
51
|
+
|
|
52
|
+
export interface EngineCandidate {
|
|
53
|
+
identifier: string;
|
|
54
|
+
root: string;
|
|
55
|
+
source: SourceKind;
|
|
56
|
+
rank: number;
|
|
57
|
+
directoryExists: boolean;
|
|
58
|
+
/** `IsValidRootDirectory`: `Engine/Binaries` and `Engine/Build` both present. */
|
|
59
|
+
validRoot: boolean;
|
|
60
|
+
version?: EngineVersion;
|
|
61
|
+
/** MajorMinor of `version`, when the root is readable. */
|
|
62
|
+
versionMajorMinor?: string;
|
|
63
|
+
/** False when the association is version-shaped and disagrees with `Build.version`. */
|
|
64
|
+
versionMatches: boolean;
|
|
65
|
+
notes: string[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface EngineResolution {
|
|
69
|
+
status: ResolutionStatus;
|
|
70
|
+
projectPath?: string;
|
|
71
|
+
projectDir?: string;
|
|
72
|
+
engineAssociation?: string;
|
|
73
|
+
associationKind: AssociationKind;
|
|
74
|
+
/** Set when `status === "resolved"`, and the top-ranked candidate when ambiguous. */
|
|
75
|
+
engineRoot?: string;
|
|
76
|
+
identifier?: string;
|
|
77
|
+
version?: EngineVersion;
|
|
78
|
+
source?: SourceKind;
|
|
79
|
+
buildVersionPath?: string;
|
|
80
|
+
/** `Engine/Build/InstalledBuild.txt` present → launcher/installed build, else source build. */
|
|
81
|
+
installedBuild?: boolean;
|
|
82
|
+
candidates: EngineCandidate[];
|
|
83
|
+
diagnostics: string[];
|
|
84
|
+
remediation: string[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface EngineInstall {
|
|
88
|
+
identifier: string;
|
|
89
|
+
root: string;
|
|
90
|
+
source: SourceKind;
|
|
91
|
+
directoryExists: boolean;
|
|
92
|
+
validRoot: boolean;
|
|
93
|
+
version?: EngineVersion;
|
|
94
|
+
versionMajorMinor?: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface ResolverDeps {
|
|
98
|
+
fs?: FsAdapter;
|
|
99
|
+
registry?: RegistryReader;
|
|
100
|
+
/** Extra engine roots to trust, ranked above registry sources (user-supplied overrides). */
|
|
101
|
+
userEngineRoots?: string[];
|
|
102
|
+
/** Environment map, for tests. Defaults to `process.env`. */
|
|
103
|
+
env?: Record<string, string | undefined>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const SOURCE_RANK: Record<SourceKind, number> = {
|
|
107
|
+
"user-hint": 0,
|
|
108
|
+
"uproject-inline-path": 1,
|
|
109
|
+
"launcher-dat": 2,
|
|
110
|
+
"hklm-version": 3,
|
|
111
|
+
"hkcu-builds": 4,
|
|
112
|
+
"hklm-installdir": 5,
|
|
113
|
+
env: 6,
|
|
114
|
+
"dir-scan": 7,
|
|
115
|
+
"walk-up": 8,
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** Environment variables this machine convention uses for the engine root. */
|
|
119
|
+
const ENGINE_ROOT_ENV_VARS = ["PI_UNREAL_ENGINE_ROOT", "UNREAL_ENGINE", "UE_ROOT"];
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* `IsValidRootDirectory` equivalent, plus a version read. Cheap: two `stat` calls and one
|
|
123
|
+
* small file read, so it is safe to call per candidate.
|
|
124
|
+
*/
|
|
125
|
+
export function isValidEngineRoot(root: string, fs: FsAdapter = realFs): boolean {
|
|
126
|
+
if (!root || !fs.isDirectory(root)) return false;
|
|
127
|
+
return fs.exists(joinPath(root, "Engine/Binaries")) && fs.exists(joinPath(root, "Engine/Build"));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function isGuidLike(identifier: string): boolean {
|
|
131
|
+
return /^\{?[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\}?$/.test(identifier.trim());
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function classifyAssociation(association: string | undefined): AssociationKind {
|
|
135
|
+
if (!association || association.trim() === "") return "empty";
|
|
136
|
+
const trimmed = association.trim();
|
|
137
|
+
if (trimmed.includes("/") || trimmed.includes("\\")) return "inline-path";
|
|
138
|
+
if (isGuidLike(trimmed)) return "guid";
|
|
139
|
+
if (/^\d+\.\d+\.\d+$/.test(trimmed)) return "legacy";
|
|
140
|
+
if (/^\d+\.\d+$/.test(trimmed)) return "version";
|
|
141
|
+
// 4.0-era bare "EngineVersion" strings and typos land here; they are matched verbatim.
|
|
142
|
+
return "unknown";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** `UE_5.8` -> `5.8`, `5.8.4` -> `5.8`, anything else unchanged. */
|
|
146
|
+
function normalizeAssociation(identifier: string): string {
|
|
147
|
+
const trimmed = identifier.trim();
|
|
148
|
+
const launcherMatch = trimmed.match(/^UE_(\d+\.\d+)/i);
|
|
149
|
+
if (launcherMatch?.[1]) return launcherMatch[1];
|
|
150
|
+
const threePart = trimmed.match(/^(\d+\.\d+)\.\d+$/);
|
|
151
|
+
if (threePart?.[1]) return threePart[1];
|
|
152
|
+
return trimmed;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function readSnapshot(registry: RegistryReader): Promise<RegistrySnapshot> {
|
|
156
|
+
try {
|
|
157
|
+
return await registry();
|
|
158
|
+
} catch (error) {
|
|
159
|
+
return {
|
|
160
|
+
available: false,
|
|
161
|
+
hklmVersions: [],
|
|
162
|
+
hkcuBuilds: [],
|
|
163
|
+
launcherDatPath: "",
|
|
164
|
+
launcherDatExists: false,
|
|
165
|
+
error: error instanceof Error ? error.message : String(error),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Candidate engine roots known to this machine, ranked, each validated. */
|
|
171
|
+
export async function enumerateInstallations(deps: ResolverDeps = {}): Promise<EngineInstall[]> {
|
|
172
|
+
const fs = deps.fs ?? realFs;
|
|
173
|
+
const snapshot = await readSnapshot(deps.registry ?? noRegistry);
|
|
174
|
+
const out: EngineInstall[] = [];
|
|
175
|
+
const seen = new Set<string>();
|
|
176
|
+
|
|
177
|
+
const add = (identifierHint: string, rawRoot: string | undefined, source: SourceKind): void => {
|
|
178
|
+
if (!rawRoot) return;
|
|
179
|
+
const root = normalizePath(rawRoot);
|
|
180
|
+
const directoryExists = fs.isDirectory(root);
|
|
181
|
+
const validRoot = isValidEngineRoot(root, fs);
|
|
182
|
+
const version = validRoot ? readBuildVersion(root, fs) : undefined;
|
|
183
|
+
const versionMajorMinor = versionToMajorMinor(version);
|
|
184
|
+
// Registry and launcher entries carry a usable identifier; env and user overrides do not, so
|
|
185
|
+
// their identifier is derived from the engine's own Build.version.
|
|
186
|
+
const derived = isGuidLike(identifierHint)
|
|
187
|
+
? identifierHint
|
|
188
|
+
: /^\d+\.\d+/.test(identifierHint) ? normalizeAssociation(identifierHint) : versionMajorMinor ?? identifierHint;
|
|
189
|
+
const key = `${derived.toLowerCase()}|${root.toLowerCase()}`;
|
|
190
|
+
if (seen.has(key)) return;
|
|
191
|
+
seen.add(key);
|
|
192
|
+
out.push({
|
|
193
|
+
identifier: derived,
|
|
194
|
+
root,
|
|
195
|
+
source,
|
|
196
|
+
directoryExists,
|
|
197
|
+
validRoot,
|
|
198
|
+
version,
|
|
199
|
+
versionMajorMinor,
|
|
200
|
+
});
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
for (const root of deps.userEngineRoots ?? []) add("", root, "user-hint");
|
|
204
|
+
for (const entry of snapshot.launcherDat ?? []) {
|
|
205
|
+
if (!entry.appName || !entry.installLocation) continue;
|
|
206
|
+
add(entry.appName, entry.installLocation, "launcher-dat");
|
|
207
|
+
}
|
|
208
|
+
for (const key of snapshot.hklmVersions) add(key.version, key.installedDirectory ?? key.installDirDefault, "hklm-version");
|
|
209
|
+
for (const build of snapshot.hkcuBuilds) add(build.identifier, build.root, "hkcu-builds");
|
|
210
|
+
if (snapshot.installDirDefault) add("installdir", snapshot.installDirDefault, "hklm-installdir");
|
|
211
|
+
|
|
212
|
+
const env = deps.env ?? process.env;
|
|
213
|
+
for (const name of ENGINE_ROOT_ENV_VARS) {
|
|
214
|
+
const value = env[name];
|
|
215
|
+
if (value) add(`env:${name}`, value, "env");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Supplemental heuristic (not part of Epic's algorithm): the launcher installs engines as
|
|
219
|
+
// siblings under `%ProgramFiles%\Epic Games`, which is how an engine with no registry
|
|
220
|
+
// presence (this machine's UE_5.8) is found at all.
|
|
221
|
+
for (const parent of candidateScanRoots(env, snapshot)) {
|
|
222
|
+
for (const child of fs.listDir(parent)) {
|
|
223
|
+
const candidate = joinPath(parent, child);
|
|
224
|
+
if (fs.isDirectory(candidate) && /^UE_\d+\.\d+$/i.test(child)) add(child.replace(/^UE_/i, ""), candidate, "dir-scan");
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
out.sort((a, b) => SOURCE_RANK[a.source] - SOURCE_RANK[b.source] || a.identifier.localeCompare(b.identifier));
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function candidateScanRoots(env: Record<string, string | undefined>, snapshot: RegistrySnapshot): string[] {
|
|
233
|
+
const roots = new Set<string>();
|
|
234
|
+
const programFiles = env["ProgramFiles"] ?? env["PROGRAMFILES"] ?? "C:/Program Files";
|
|
235
|
+
roots.add(joinPath(normalizePath(programFiles), "Epic Games"));
|
|
236
|
+
// An engine install recorded anywhere gives its parent directory as a scan root.
|
|
237
|
+
const dirs = [
|
|
238
|
+
...(snapshot.launcherDat ?? []).map((entry) => entry.installLocation),
|
|
239
|
+
...snapshot.hklmVersions.map((entry) => entry.installedDirectory),
|
|
240
|
+
snapshot.installDirDefault,
|
|
241
|
+
...(snapshot.hkcuBuilds ?? []).map((entry) => entry.root),
|
|
242
|
+
];
|
|
243
|
+
for (const dir of dirs) {
|
|
244
|
+
if (!dir) continue;
|
|
245
|
+
const parent = parentDir(normalizePath(dir));
|
|
246
|
+
if (parent) roots.add(parent);
|
|
247
|
+
}
|
|
248
|
+
return [...roots];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function makeCandidate(
|
|
252
|
+
identifier: string,
|
|
253
|
+
root: string,
|
|
254
|
+
source: SourceKind,
|
|
255
|
+
fs: FsAdapter,
|
|
256
|
+
targetMajorMinor: string | undefined,
|
|
257
|
+
notes: string[],
|
|
258
|
+
): EngineCandidate {
|
|
259
|
+
const directoryExists = fs.isDirectory(root);
|
|
260
|
+
const validRoot = isValidEngineRoot(root, fs);
|
|
261
|
+
const version = validRoot ? readBuildVersion(root, fs) : undefined;
|
|
262
|
+
const versionMajorMinor = versionToMajorMinor(version);
|
|
263
|
+
const versionMatches = !targetMajorMinor || !versionMajorMinor || versionMajorMinor === targetMajorMinor;
|
|
264
|
+
if (!directoryExists) notes.push("directory does not exist");
|
|
265
|
+
else if (!validRoot) notes.push("missing Engine/Binaries or Engine/Build (not an engine root)");
|
|
266
|
+
else if (!version) notes.push("Engine/Build/Build.version unreadable");
|
|
267
|
+
else if (!versionMatches) notes.push(`Build.version ${versionMajorMinor} does not match association ${targetMajorMinor}`);
|
|
268
|
+
return {
|
|
269
|
+
identifier: normalizeAssociation(identifier),
|
|
270
|
+
root,
|
|
271
|
+
source,
|
|
272
|
+
rank: SOURCE_RANK[source],
|
|
273
|
+
directoryExists,
|
|
274
|
+
validRoot,
|
|
275
|
+
version,
|
|
276
|
+
versionMajorMinor,
|
|
277
|
+
versionMatches,
|
|
278
|
+
notes,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Resolve the engine a project belongs to.
|
|
284
|
+
*
|
|
285
|
+
* Never returns a silent guess: `status` is `resolved` only when exactly one candidate is a
|
|
286
|
+
* valid engine root that matches the association. `ambiguous` ranks every survivor and names
|
|
287
|
+
* the top one; `unresolved` lists the dead candidates with the reason each one died.
|
|
288
|
+
*/
|
|
289
|
+
export async function resolveEngineForProject(
|
|
290
|
+
projectPath: string,
|
|
291
|
+
deps: ResolverDeps = {},
|
|
292
|
+
): Promise<EngineResolution> {
|
|
293
|
+
const fs = deps.fs ?? realFs;
|
|
294
|
+
const registry = deps.registry ?? noRegistry;
|
|
295
|
+
const normalizedProject = normalizePath(projectPath);
|
|
296
|
+
const projectDir = parentDir(normalizedProject) ?? normalizedProject;
|
|
297
|
+
const diagnostics: string[] = [];
|
|
298
|
+
const remediation: string[] = [];
|
|
299
|
+
|
|
300
|
+
const descriptor = normalizedProject.toLowerCase().endsWith(".uproject")
|
|
301
|
+
? parseUproject(normalizedProject, fs)
|
|
302
|
+
: undefined;
|
|
303
|
+
if (descriptor) {
|
|
304
|
+
for (const line of descriptor.diagnostics) diagnostics.push(line);
|
|
305
|
+
}
|
|
306
|
+
const association = descriptor?.engineAssociation;
|
|
307
|
+
const associationKind = classifyAssociation(association);
|
|
308
|
+
const trimmedAssociation = association?.trim() ?? "";
|
|
309
|
+
const targetMajorMinor =
|
|
310
|
+
associationKind === "version" || associationKind === "legacy" || associationKind === "unknown" ? normalizeAssociation(trimmedAssociation) : undefined;
|
|
311
|
+
|
|
312
|
+
const candidates: EngineCandidate[] = [];
|
|
313
|
+
|
|
314
|
+
// (1) `EngineAssociation` as an inline engine-root path.
|
|
315
|
+
if (associationKind === "inline-path") {
|
|
316
|
+
const raw = trimmedAssociation.replace(/\\/g, "/");
|
|
317
|
+
const root = isAbsolutePath(raw) ? normalizePath(raw) : normalizePath(joinPath(projectDir, raw));
|
|
318
|
+
candidates.push(makeCandidate(root, root, "uproject-inline-path", fs, targetMajorMinor, []));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// (2) `EngineAssociation` empty → walk up from the project directory (source builds).
|
|
322
|
+
if (associationKind === "empty") {
|
|
323
|
+
walkUp(projectDir, (dir) => {
|
|
324
|
+
if (isValidEngineRoot(dir, fs)) {
|
|
325
|
+
candidates.push(makeCandidate(dir, dir, "walk-up", fs, undefined, ["EngineAssociation is empty; matched the enclosing source tree"]));
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
return undefined;
|
|
329
|
+
}, 12);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const versionish = associationKind === "version" || associationKind === "legacy" || associationKind === "unknown";
|
|
333
|
+
const guid = associationKind === "guid";
|
|
334
|
+
|
|
335
|
+
if (versionish || guid) {
|
|
336
|
+
const installs = await enumerateInstallations({ fs, registry, userEngineRoots: deps.userEngineRoots, env: deps.env });
|
|
337
|
+
for (const install of installs) {
|
|
338
|
+
if (guid) {
|
|
339
|
+
// A GUID association is only ever resolvable through `HKCU\...\Builds`, where the
|
|
340
|
+
// value name is the identifier the project carries.
|
|
341
|
+
if (install.source !== "hkcu-builds" || !sameGuid(install.identifier, trimmedAssociation)) continue;
|
|
342
|
+
} else if (targetMajorMinor && install.identifier.toLowerCase() !== targetMajorMinor.toLowerCase()) {
|
|
343
|
+
// A pointer that exists but is not an engine root stays in the list: "your UNREAL_ENGINE
|
|
344
|
+
// points at a directory that is not an engine root" is the diagnostic the agent needs.
|
|
345
|
+
if (install.validRoot) continue;
|
|
346
|
+
}
|
|
347
|
+
candidates.push(makeCandidate(install.identifier, install.root, install.source, fs, targetMajorMinor, []));
|
|
348
|
+
}
|
|
349
|
+
const snapshot = await readSnapshot(registry);
|
|
350
|
+
if (!snapshot.available && snapshot.error) diagnostics.push(`registry sources unavailable: ${snapshot.error}`);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Deduplicate by root, keeping the best-ranked entry per root.
|
|
354
|
+
const byRoot = new Map<string, EngineCandidate>();
|
|
355
|
+
for (const candidate of candidates) {
|
|
356
|
+
const key = candidate.root.toLowerCase();
|
|
357
|
+
const existing = byRoot.get(key);
|
|
358
|
+
if (!existing || candidate.rank < existing.rank) byRoot.set(key, candidate);
|
|
359
|
+
}
|
|
360
|
+
const ranked = [...byRoot.values()].sort(
|
|
361
|
+
(a, b) => Number(b.validRoot) - Number(a.validRoot) || Number(b.versionMatches) - Number(a.versionMatches) || a.rank - b.rank,
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
const base = {
|
|
365
|
+
projectPath: normalizedProject,
|
|
366
|
+
projectDir,
|
|
367
|
+
engineAssociation: association,
|
|
368
|
+
associationKind,
|
|
369
|
+
candidates: ranked,
|
|
370
|
+
diagnostics,
|
|
371
|
+
remediation,
|
|
372
|
+
} satisfies Omit<EngineResolution, "status">;
|
|
373
|
+
|
|
374
|
+
const survivors = ranked.filter((candidate) => candidate.validRoot && candidate.versionMatches);
|
|
375
|
+
|
|
376
|
+
if (survivors.length === 1) {
|
|
377
|
+
const winner = survivors[0];
|
|
378
|
+
if (!winner) return { ...base, status: "unresolved" };
|
|
379
|
+
return {
|
|
380
|
+
...base,
|
|
381
|
+
status: "resolved",
|
|
382
|
+
engineRoot: winner.root,
|
|
383
|
+
identifier: winner.identifier,
|
|
384
|
+
version: winner.version,
|
|
385
|
+
source: winner.source,
|
|
386
|
+
buildVersionPath: joinPath(winner.root, "Engine/Build/Build.version"),
|
|
387
|
+
installedBuild: fs.exists(joinPath(winner.root, "Engine/Build/InstalledBuild.txt")),
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (survivors.length > 1) {
|
|
392
|
+
const winner = survivors[0];
|
|
393
|
+
remediation.push(
|
|
394
|
+
`more than one valid engine root matches (${survivors.map((c) => `${c.identifier} via ${c.source} → ${c.root}`).join("; ")}); pass the intended root explicitly`,
|
|
395
|
+
);
|
|
396
|
+
return {
|
|
397
|
+
...base,
|
|
398
|
+
status: "ambiguous",
|
|
399
|
+
engineRoot: winner?.root,
|
|
400
|
+
identifier: winner?.identifier,
|
|
401
|
+
version: winner?.version,
|
|
402
|
+
source: winner?.source,
|
|
403
|
+
buildVersionPath: winner ? joinPath(winner.root, "Engine/Build/Build.version") : undefined,
|
|
404
|
+
installedBuild: winner ? fs.exists(joinPath(winner.root, "Engine/Build/InstalledBuild.txt")) : undefined,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Nothing survived. Explain why, and give the engine-side fix.
|
|
409
|
+
if (associationKind === "inline-path") {
|
|
410
|
+
remediation.push(`EngineAssociation names a path (${trimmedAssociation}) that is not a valid engine root for ${normalizedProject}`);
|
|
411
|
+
} else if (associationKind === "guid") {
|
|
412
|
+
remediation.push(
|
|
413
|
+
`EngineAssociation ${trimmedAssociation} has no entry under HKCU\\SOFTWARE\\Epic Games\\Unreal Engine\\Builds; open the project with the matching source build (its GeneratedProjectFiles / UBT run registers the key)`,
|
|
414
|
+
);
|
|
415
|
+
} else if (associationKind === "empty") {
|
|
416
|
+
remediation.push(`no enclosing engine root found above ${projectDir}; set EngineAssociation or run from inside the source tree`);
|
|
417
|
+
} else {
|
|
418
|
+
remediation.push(
|
|
419
|
+
targetMajorMinor
|
|
420
|
+
? `no readable engine root for association ${targetMajorMinor}; set EngineAssociation to a UE_${targetMajorMinor} install directory, a source-build GUID, or an engine-root path`
|
|
421
|
+
: `cannot interpret EngineAssociation "${trimmedAssociation}"`,
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
if (ranked.length > 0) {
|
|
425
|
+
remediation.push(`candidates considered: ${ranked.map((c) => `${c.root} (${c.source}; ${c.notes.join("; ") || "ok"})`).join(" | ")}`);
|
|
426
|
+
}
|
|
427
|
+
return { ...base, status: "unresolved" };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function sameGuid(a: string, b: string): boolean {
|
|
431
|
+
const strip = (value: string) => value.trim().replace(/[{}]/g, "").toLowerCase();
|
|
432
|
+
return strip(a) === strip(b);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Ancestor directories of `cwd`, shallowest last — used to find a project to talk about. */
|
|
436
|
+
export function findProjectFile(startDir: string, fs: FsAdapter = realFs, maxDepth = 4): string | undefined {
|
|
437
|
+
for (const dir of ancestors(startDir, maxDepth)) {
|
|
438
|
+
for (const entry of fs.listDir(dir)) {
|
|
439
|
+
if (entry.toLowerCase().endsWith(".uproject")) return joinPath(dir, entry);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return undefined;
|
|
443
|
+
}
|