@aeonsparx/pi-unreal 0.0.0-stage → 1.1.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/CHANGELOG.md +89 -0
- package/LICENSE +21 -0
- package/README.md +199 -2
- package/docs/internals.md +245 -0
- package/extensions/unreal/index.ts +696 -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 +452 -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 +1490 -0
- package/extensions/unreal/lib/tools.ts +1074 -0
- package/extensions/unreal/lib/ubt.ts +380 -0
- package/extensions/unreal/lib/util.ts +210 -0
- package/package.json +60 -4
- package/prompts/unreal-context.md +36 -0
- package/skills/ue-engine-resolution/SKILL.md +129 -0
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Descriptor parsers for `.uproject`, `.uplugin`, and the nested structures they embed.
|
|
3
|
+
*
|
|
4
|
+
* Field tables are transcribed from the engine's own serialization code (UE 5.8):
|
|
5
|
+
* Engine/Source/Runtime/Projects/Public/ProjectDescriptor.h:76-106,193-199 FProjectDescriptor
|
|
6
|
+
* Engine/Source/Runtime/Projects/Public/PluginDescriptor.h:43-177 FPluginFileDescriptor
|
|
7
|
+
* Engine/Source/Runtime/Projects/Public/ModuleDescriptor.h:160-205 FModuleDescriptor
|
|
8
|
+
* Engine/Source/Runtime/Projects/Private/PluginReferenceDescriptor.cpp:132-172 FPluginReferenceDescriptor
|
|
9
|
+
* Engine/Source/Runtime/Core/Public/Misc/EngineVersion.h FEngineVersion
|
|
10
|
+
*
|
|
11
|
+
* Anything the engine does not serialize is reported as an unknown field, so a schema drift
|
|
12
|
+
* shows up as a diagnostic instead of silently disappearing.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { joinPath, parseJsoncLenient, readFileSafe, realFs, type FsAdapter, type JsonValue } from "./util.ts";
|
|
16
|
+
|
|
17
|
+
// EHostType::ToString — ModuleDescriptor.cpp:110-146 (UE 5.8). These are the exact strings a
|
|
18
|
+
// `.uplugin`/`.uproject` must use; `EHostType::FromString` returns Max for anything else and
|
|
19
|
+
// FModuleDescriptor::Read then fails the whole descriptor.
|
|
20
|
+
export const HOST_TYPES = [
|
|
21
|
+
"Runtime",
|
|
22
|
+
"RuntimeNoCommandlet",
|
|
23
|
+
"RuntimeAndProgram",
|
|
24
|
+
"CookedOnly",
|
|
25
|
+
"UncookedOnly",
|
|
26
|
+
"Developer",
|
|
27
|
+
"DeveloperTool",
|
|
28
|
+
"Editor",
|
|
29
|
+
"EditorNoCommandlet",
|
|
30
|
+
"EditorAndProgram",
|
|
31
|
+
"Program",
|
|
32
|
+
"ServerOnly",
|
|
33
|
+
"ClientOnly",
|
|
34
|
+
"ClientOnlyNoCommandlet",
|
|
35
|
+
] as const;
|
|
36
|
+
export type HostType = (typeof HOST_TYPES)[number];
|
|
37
|
+
|
|
38
|
+
// ELoadingPhase::Type — ModuleDescriptor.h:24-52 (UE 5.8).
|
|
39
|
+
export const LOADING_PHASES = [
|
|
40
|
+
"EarliestPossible",
|
|
41
|
+
"PostConfigInit",
|
|
42
|
+
"PostSplashScreen",
|
|
43
|
+
"PreEarlyLoadingScreen",
|
|
44
|
+
"PreLoadingScreen",
|
|
45
|
+
"PreDefault",
|
|
46
|
+
"Default",
|
|
47
|
+
"PostDefault",
|
|
48
|
+
"PostEngineInit",
|
|
49
|
+
"None",
|
|
50
|
+
] as const;
|
|
51
|
+
export type LoadingPhase = (typeof LOADING_PHASES)[number];
|
|
52
|
+
|
|
53
|
+
/** `FEngineVersion` (`Major.Minor.Patch-Changelist+BranchName`). */
|
|
54
|
+
export interface EngineVersion {
|
|
55
|
+
major: number;
|
|
56
|
+
minor: number;
|
|
57
|
+
patch: number;
|
|
58
|
+
changelist: number;
|
|
59
|
+
branchName?: string;
|
|
60
|
+
raw: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ModuleDescriptor {
|
|
64
|
+
/** `"Name"` is only present in legacy descriptors; the `.uproject` map key is authoritative. */
|
|
65
|
+
name?: string;
|
|
66
|
+
type?: HostType;
|
|
67
|
+
loadingPhase?: LoadingPhase;
|
|
68
|
+
platformAllowList: string[];
|
|
69
|
+
platformDenyList: string[];
|
|
70
|
+
targetAllowList: string[];
|
|
71
|
+
targetDenyList: string[];
|
|
72
|
+
additionalDependencies: string[];
|
|
73
|
+
bUnknown: boolean;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface PluginReference {
|
|
77
|
+
name: string;
|
|
78
|
+
enabled: boolean;
|
|
79
|
+
optional: boolean;
|
|
80
|
+
/** `Activate` — `true` means "load even when the referencing module does not require it". */
|
|
81
|
+
activate: boolean;
|
|
82
|
+
/** Encoded `FFileVersion` (`(Major << 16) | Minor`) as found in the file. */
|
|
83
|
+
version?: number;
|
|
84
|
+
semVersion?: string;
|
|
85
|
+
supportedTargetPlatforms: string[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface UprojectDescriptor {
|
|
89
|
+
path: string;
|
|
90
|
+
engineAssociation?: string;
|
|
91
|
+
category?: string;
|
|
92
|
+
description?: string;
|
|
93
|
+
/** `Enterprise` — the project is an enterprise (Launcher-adjacent) project. */
|
|
94
|
+
enterprise: boolean;
|
|
95
|
+
/** `DisableEnginePluginsByDefault` — engine plugins default to disabled for this project. */
|
|
96
|
+
disableEnginePluginsByDefault: boolean;
|
|
97
|
+
modules: { moduleName: string; descriptor: ModuleDescriptor }[];
|
|
98
|
+
plugins: PluginReference[];
|
|
99
|
+
targetPlatforms: string[];
|
|
100
|
+
additionalPluginDirectories: string[];
|
|
101
|
+
additionalRootDirectories: string[];
|
|
102
|
+
/** Engine-serialized fields we know about, keyed by JSON name. */
|
|
103
|
+
knownFields: string[];
|
|
104
|
+
/** Keys present in the file that `FProjectDescriptor` does not serialize. */
|
|
105
|
+
unknownFields: string[];
|
|
106
|
+
diagnostics: string[];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface UpluginDescriptor {
|
|
110
|
+
path: string;
|
|
111
|
+
name: string;
|
|
112
|
+
/** Decoded from the `Version` int (`FFileVersion`), e.g. `1.0`. */
|
|
113
|
+
version?: string;
|
|
114
|
+
/** Raw int form. */
|
|
115
|
+
versionRaw?: number;
|
|
116
|
+
semVersion?: string;
|
|
117
|
+
versionName?: string;
|
|
118
|
+
engineVersion?: EngineVersion;
|
|
119
|
+
friendlyName?: string;
|
|
120
|
+
category?: string;
|
|
121
|
+
description?: string;
|
|
122
|
+
enabledByDefault: boolean;
|
|
123
|
+
canContainContent: boolean;
|
|
124
|
+
canContainVerse: boolean;
|
|
125
|
+
noCode: boolean;
|
|
126
|
+
isBetaVersion: boolean;
|
|
127
|
+
isExperimentalVersion: boolean;
|
|
128
|
+
/** `Installed` — the plugin ships inside an installed engine build. */
|
|
129
|
+
installed: boolean;
|
|
130
|
+
requiresBuildPlatform: boolean;
|
|
131
|
+
isHidden: boolean;
|
|
132
|
+
isSealed: boolean;
|
|
133
|
+
explicitlyLoaded: boolean;
|
|
134
|
+
hasExplicitPlatforms: boolean;
|
|
135
|
+
isPluginExtension: boolean;
|
|
136
|
+
createdBy?: string;
|
|
137
|
+
docsUrl?: string;
|
|
138
|
+
marketplaceUrl?: string;
|
|
139
|
+
supportUrl?: string;
|
|
140
|
+
supportedTargetPlatforms: string[];
|
|
141
|
+
supportedPrograms: string[];
|
|
142
|
+
platformAllowList: string[];
|
|
143
|
+
platformDenyList: string[];
|
|
144
|
+
targetAllowList: string[];
|
|
145
|
+
targetDenyList: string[];
|
|
146
|
+
modules: { moduleName: string; descriptor: ModuleDescriptor }[];
|
|
147
|
+
plugins: PluginReference[];
|
|
148
|
+
knownFields: string[];
|
|
149
|
+
unknownFields: string[];
|
|
150
|
+
diagnostics: string[];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Keys FProjectDescriptor::Read actually reads — ProjectDescriptor.cpp:104-221 (UE 5.8).
|
|
154
|
+
const UPROJECT_FIELDS = new Set([
|
|
155
|
+
"FileVersion",
|
|
156
|
+
"ProjectFileVersion",
|
|
157
|
+
"EngineAssociation",
|
|
158
|
+
"Category",
|
|
159
|
+
"Description",
|
|
160
|
+
"Enterprise",
|
|
161
|
+
"DisableEnginePluginsByDefault",
|
|
162
|
+
"Modules",
|
|
163
|
+
"Plugins",
|
|
164
|
+
"TargetPlatforms",
|
|
165
|
+
"EpicSampleNameHash",
|
|
166
|
+
"AdditionalRootDirectories",
|
|
167
|
+
"AdditionalPluginDirectories",
|
|
168
|
+
"PreBuildSteps",
|
|
169
|
+
"PostBuildSteps",
|
|
170
|
+
"AdditionalDirectories",
|
|
171
|
+
"EngineGuid",
|
|
172
|
+
]);
|
|
173
|
+
|
|
174
|
+
// Keys FPluginDescriptor::Read actually reads — PluginDescriptor.cpp:263-410 (UE 5.8).
|
|
175
|
+
const UPLUGIN_FIELDS = new Set([
|
|
176
|
+
"FileVersion",
|
|
177
|
+
"PluginFileVersion",
|
|
178
|
+
"Version",
|
|
179
|
+
"SemVersion",
|
|
180
|
+
"VersionName",
|
|
181
|
+
"EngineVersion",
|
|
182
|
+
"EngineGuid",
|
|
183
|
+
"Name",
|
|
184
|
+
"FriendlyName",
|
|
185
|
+
"Category",
|
|
186
|
+
"CategoryPath",
|
|
187
|
+
"Description",
|
|
188
|
+
"CreatedBy",
|
|
189
|
+
"CreatedByURL",
|
|
190
|
+
"DocsURL",
|
|
191
|
+
"MarketplaceURL",
|
|
192
|
+
"SupportURL",
|
|
193
|
+
"DeprecationURL",
|
|
194
|
+
"EditorCustomVirtualPath",
|
|
195
|
+
"EnabledByDefault",
|
|
196
|
+
/* `Enabled` is a PLUGIN-REFERENCE key (PluginReferenceDescriptor.cpp:138); PluginDescriptor.cpp
|
|
197
|
+
never reads it at plugin scope (only `EnabledByDefault`, :361-365). 153 shipped `.uplugin`
|
|
198
|
+
files carry it anyway (census), so it stays known and keeps the diagnostic clean. Runtime
|
|
199
|
+
enablement is FPlugin::bEnabled (PluginManager.cpp:380) — derived from references, not the descriptor. */
|
|
200
|
+
"Enabled",
|
|
201
|
+
"CanContainContent",
|
|
202
|
+
"CanContainVerse",
|
|
203
|
+
"NoCode",
|
|
204
|
+
"NoGameFeatureContent",
|
|
205
|
+
"IsBetaVersion",
|
|
206
|
+
"IsExperimentalVersion",
|
|
207
|
+
"Installed",
|
|
208
|
+
"RequiresBuildPlatform",
|
|
209
|
+
"Hidden",
|
|
210
|
+
"Sealed",
|
|
211
|
+
"ExplicitlyLoaded",
|
|
212
|
+
"HasExplicitPlatforms",
|
|
213
|
+
"bIsPluginExtension",
|
|
214
|
+
"CanBeUsedWithUnrealHeaderTool",
|
|
215
|
+
"EnableSceneGraph",
|
|
216
|
+
"EnableSceneGraphAnimation",
|
|
217
|
+
"EnableVerseAssetReflection",
|
|
218
|
+
"EnableIAD",
|
|
219
|
+
"EnableIAS",
|
|
220
|
+
"VersePath",
|
|
221
|
+
"VerseScope",
|
|
222
|
+
"VerseVersion",
|
|
223
|
+
"DeprecatedEngineVersion",
|
|
224
|
+
"LocalizationTargets",
|
|
225
|
+
"Modules",
|
|
226
|
+
"Plugins",
|
|
227
|
+
"DisallowedPlugins",
|
|
228
|
+
"SupportedTargetPlatforms",
|
|
229
|
+
"SupportedPrograms",
|
|
230
|
+
"PlatformAllowList",
|
|
231
|
+
"PlatformDenyList",
|
|
232
|
+
"TargetAllowList",
|
|
233
|
+
"TargetDenyList",
|
|
234
|
+
"TargetConfigurationAllowList",
|
|
235
|
+
"TargetConfigurationDenyList",
|
|
236
|
+
"WhitelistPlatforms",
|
|
237
|
+
"BlacklistPlatforms",
|
|
238
|
+
"WhitelistTargets",
|
|
239
|
+
"BlacklistTargets",
|
|
240
|
+
"PreBuildSteps",
|
|
241
|
+
"PostBuildSteps",
|
|
242
|
+
"MetadataURL",
|
|
243
|
+
"EnterprisePluginName",
|
|
244
|
+
/* Keys found in real shipped `.uplugin` files (census over 925 files: UE 5.8 Engine/Plugins,
|
|
245
|
+
the Lp bench's Plugins/, Unreal Projects/Lp/Plugins, LyraStarterGame/Plugins). They are not
|
|
246
|
+
read by FPluginDescriptor; listing them keeps the unknown-field diagnostic a signal instead
|
|
247
|
+
of a shout at every third-party plugin. */
|
|
248
|
+
"EditorOnly",
|
|
249
|
+
"NoRedist",
|
|
250
|
+
"PythonRequirements",
|
|
251
|
+
"PythonScriptFolderName",
|
|
252
|
+
"IsExperimental",
|
|
253
|
+
"BuiltInInitialFeatureState",
|
|
254
|
+
"BuiltInInitialContentState",
|
|
255
|
+
"PluginIconPath",
|
|
256
|
+
"bRequiresBuildPlatform",
|
|
257
|
+
"IsHidden",
|
|
258
|
+
"HideScreen",
|
|
259
|
+
"HideByDefault",
|
|
260
|
+
"FolderIdentifier",
|
|
261
|
+
"Enterprise",
|
|
262
|
+
"builtInAutoActivate",
|
|
263
|
+
]);
|
|
264
|
+
|
|
265
|
+
const MODULE_FIELDS = new Set([
|
|
266
|
+
"Name",
|
|
267
|
+
"Type",
|
|
268
|
+
"LoadingPhase",
|
|
269
|
+
"PlatformAllowList",
|
|
270
|
+
"PlatformDenyList",
|
|
271
|
+
"HasExplicitPlatforms",
|
|
272
|
+
"TargetAllowList",
|
|
273
|
+
"TargetDenyList",
|
|
274
|
+
"TargetConfigurationAllowList",
|
|
275
|
+
"TargetConfigurationDenyList",
|
|
276
|
+
"ProgramAllowList",
|
|
277
|
+
"ProgramDenyList",
|
|
278
|
+
"AdditionalDependencies",
|
|
279
|
+
"GameTargetAllowList",
|
|
280
|
+
"GameTargetDenyList",
|
|
281
|
+
"PlatformArchitectureAllowList",
|
|
282
|
+
"PlatformArchitectureDenyList",
|
|
283
|
+
"WhitelistPlatforms",
|
|
284
|
+
"BlacklistPlatforms",
|
|
285
|
+
"WhitelistTargets",
|
|
286
|
+
"BlacklistTargets",
|
|
287
|
+
"WhitelistTargetConfigurations",
|
|
288
|
+
"BlacklistTargetConfigurations",
|
|
289
|
+
"WhitelistPrograms",
|
|
290
|
+
"BlacklistPrograms",
|
|
291
|
+
/* Real-file variants seen in the census (including Epic's own plugins): `ProgramAllowlist`
|
|
292
|
+
(lowercase l) and `AllowlistPrograms` are NOT read by FModuleDescriptor, so the engine
|
|
293
|
+
silently ignores them — the diagnostic that names them is deliberate. */
|
|
294
|
+
"ProgramAllowlist",
|
|
295
|
+
"AllowlistPrograms",
|
|
296
|
+
"WhitelistPlatformAllowList",
|
|
297
|
+
"BlacklistPlatformAllowList",
|
|
298
|
+
"WhitelistAdditionalDependencies",
|
|
299
|
+
"BlacklistAdditionalDependencies",
|
|
300
|
+
"SystemIncludePaths",
|
|
301
|
+
"ExtraSubstitutions",
|
|
302
|
+
"chroot",
|
|
303
|
+
]);
|
|
304
|
+
|
|
305
|
+
// FPluginReferenceDescriptor::Read — PluginReferenceDescriptor.cpp:130-180 (UE 5.8), plus keys older
|
|
306
|
+
// `.uproject` files still carry (the engine ignores unknown keys silently, so listing them keeps the
|
|
307
|
+
// diagnostic signal meaningful instead of shouting at every pre-5.x project).
|
|
308
|
+
const PLUGIN_REF_FIELDS = new Set([
|
|
309
|
+
"Name",
|
|
310
|
+
"Enabled",
|
|
311
|
+
"Optional",
|
|
312
|
+
"Activate",
|
|
313
|
+
"Description",
|
|
314
|
+
"MarketplaceURL",
|
|
315
|
+
"Version",
|
|
316
|
+
"SemVersion",
|
|
317
|
+
"SupportedTargetPlatforms",
|
|
318
|
+
"HasExplicitPlatforms",
|
|
319
|
+
"PlatformAllowList",
|
|
320
|
+
"PlatformDenyList",
|
|
321
|
+
"TargetAllowList",
|
|
322
|
+
"TargetDenyList",
|
|
323
|
+
"TargetConfigurationAllowList",
|
|
324
|
+
"TargetConfigurationDenyList",
|
|
325
|
+
"TargetsToExplicitlyLoad",
|
|
326
|
+
"WhitelistPlatforms",
|
|
327
|
+
"BlacklistPlatforms",
|
|
328
|
+
"WhitelistTargets",
|
|
329
|
+
"BlacklistTargets",
|
|
330
|
+
"WhitelistTargetConfigurations",
|
|
331
|
+
"BlacklistTargetConfigurations",
|
|
332
|
+
"AllowDuringCommandlet",
|
|
333
|
+
"HasPrebuiltData",
|
|
334
|
+
"IsStrictlyRequired",
|
|
335
|
+
"TargetName",
|
|
336
|
+
"ModuleOverridingMap",
|
|
337
|
+
]);
|
|
338
|
+
|
|
339
|
+
function asString(value: JsonValue | undefined): string | undefined {
|
|
340
|
+
return typeof value === "string" ? value : undefined;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function asBoolean(value: JsonValue | undefined, fallback = false): boolean {
|
|
344
|
+
return typeof value === "boolean" ? value : fallback;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function asNumber(value: JsonValue | undefined): number | undefined {
|
|
348
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function asStringArray(value: JsonValue | undefined): string[] {
|
|
352
|
+
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function asRecord(value: JsonValue | undefined): Record<string, JsonValue> {
|
|
356
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** `5.8.3-58210709+++UE5+Release-5.8` and the `Major.Minor.Patch` short form. */
|
|
360
|
+
export function parseEngineVersion(text: string | undefined): EngineVersion | undefined {
|
|
361
|
+
if (!text) return undefined;
|
|
362
|
+
const raw = text.trim();
|
|
363
|
+
const match = raw.match(/^(\d+)\.(\d+)\.(\d+)(?:-(\d+))?(?:\+(.*))?$/);
|
|
364
|
+
if (!match) return undefined;
|
|
365
|
+
const [, major, minor, patch, changelist, branch] = match;
|
|
366
|
+
return {
|
|
367
|
+
major: Number(major),
|
|
368
|
+
minor: Number(minor),
|
|
369
|
+
patch: Number(patch),
|
|
370
|
+
changelist: Number(changelist ?? "0"),
|
|
371
|
+
branchName: branch,
|
|
372
|
+
raw,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** `Engine/Build/Build.version` — the engine's self-reported version. */
|
|
377
|
+
export function readBuildVersion(engineRoot: string, fs: FsAdapter = realFs): EngineVersion | undefined {
|
|
378
|
+
const path = joinPath(engineRoot, "Engine/Build/Build.version");
|
|
379
|
+
const text = readFileSafe(path, fs);
|
|
380
|
+
if (!text) return undefined;
|
|
381
|
+
const { value, errors } = parseJsoncLenient<Record<string, JsonValue>>(text);
|
|
382
|
+
if (errors.length > 0) return undefined;
|
|
383
|
+
const record = asRecord(value);
|
|
384
|
+
const major = asNumber(record.MajorVersion);
|
|
385
|
+
const minor = asNumber(record.MinorVersion);
|
|
386
|
+
if (major === undefined || minor === undefined) return undefined;
|
|
387
|
+
return {
|
|
388
|
+
major,
|
|
389
|
+
minor,
|
|
390
|
+
patch: asNumber(record.PatchVersion) ?? 0,
|
|
391
|
+
changelist: asNumber(record.Changelist) ?? 0,
|
|
392
|
+
branchName: asString(record.BranchName),
|
|
393
|
+
raw: `${major}.${minor}.${asNumber(record.PatchVersion) ?? 0}`,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Decode `FFileVersion` (`(Major << 16) | Minor`). */
|
|
398
|
+
export function decodeFileVersion(version: number | undefined): string | undefined {
|
|
399
|
+
if (version === undefined) return undefined;
|
|
400
|
+
return `${(version >> 16) & 0xffff}.${version & 0xffff}`;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function parseModuleEntry(name: string, raw: JsonValue | undefined, diagnostics: string[]): ModuleDescriptor {
|
|
404
|
+
const record = asRecord(raw);
|
|
405
|
+
const type = asString(record.Type);
|
|
406
|
+
const loadingPhase = asString(record.LoadingPhase);
|
|
407
|
+
const entry: ModuleDescriptor = {
|
|
408
|
+
name: asString(record.Name),
|
|
409
|
+
/** Engine constructor default is `EHostType::Runtime` (ModuleDescriptor.h:209). */
|
|
410
|
+
type: (type ?? "Runtime") as HostType,
|
|
411
|
+
/** Engine constructor default is `ELoadingPhase::Default`. */
|
|
412
|
+
loadingPhase: (loadingPhase ?? "Default") as LoadingPhase,
|
|
413
|
+
platformAllowList: asStringArray(record.PlatformAllowList),
|
|
414
|
+
platformDenyList: asStringArray(record.PlatformDenyList),
|
|
415
|
+
targetAllowList: asStringArray(record.TargetAllowList),
|
|
416
|
+
targetDenyList: asStringArray(record.TargetDenyList),
|
|
417
|
+
additionalDependencies: asStringArray(record.AdditionalDependencies),
|
|
418
|
+
bUnknown: false,
|
|
419
|
+
};
|
|
420
|
+
if (type === undefined) {
|
|
421
|
+
// ModuleDescriptor.cpp:174 — a missing Type is a hard engine error, even though the
|
|
422
|
+
// FModuleDescriptor constructor defaults it to Runtime.
|
|
423
|
+
diagnostics.push(`module ${name}: no "Type" field (UE 5.8 rejects the descriptor: "Found Module entry '${name}' with a missing 'Type' field")`);
|
|
424
|
+
}
|
|
425
|
+
if (type !== undefined && !HOST_TYPES.includes(type as HostType)) {
|
|
426
|
+
diagnostics.push(
|
|
427
|
+
`module ${name}: Type "${type}" is not one of ${HOST_TYPES.join("|")} (EHostType::FromString returns Max and UE 5.8 rejects the descriptor)`,
|
|
428
|
+
);
|
|
429
|
+
entry.bUnknown = true;
|
|
430
|
+
}
|
|
431
|
+
if (loadingPhase !== undefined && !LOADING_PHASES.includes(loadingPhase as LoadingPhase)) {
|
|
432
|
+
diagnostics.push(`module ${name}: LoadingPhase "${loadingPhase}" is not one of ${LOADING_PHASES.join("|")}`);
|
|
433
|
+
entry.bUnknown = true;
|
|
434
|
+
}
|
|
435
|
+
for (const key of Object.keys(record)) {
|
|
436
|
+
if (!MODULE_FIELDS.has(key)) diagnostics.push(`module ${name}: field "${key}" is not serialized by FModuleDescriptor`);
|
|
437
|
+
}
|
|
438
|
+
return entry;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* `Modules` is an ARRAY of objects, each with a `Name` (ModuleDescriptor.cpp:166). A `Name`-keyed
|
|
443
|
+
* object map is accepted too because pre-UE4 files in the wild use it, and the engine's own
|
|
444
|
+
* `FModuleDescriptor::ReadArray` path is the one that must win for real projects.
|
|
445
|
+
*/
|
|
446
|
+
function parseModuleMap(raw: JsonValue | undefined, diagnostics: string[]): { moduleName: string; descriptor: ModuleDescriptor }[] {
|
|
447
|
+
const entries: { moduleName: string; descriptor: ModuleDescriptor }[] = [];
|
|
448
|
+
if (Array.isArray(raw)) {
|
|
449
|
+
for (const item of raw) {
|
|
450
|
+
const record = asRecord(item);
|
|
451
|
+
const moduleName = asString(record.Name);
|
|
452
|
+
if (!moduleName) {
|
|
453
|
+
diagnostics.push("module entry without a Name is ignored by the engine");
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
entries.push({ moduleName, descriptor: parseModuleEntry(moduleName, item, diagnostics) });
|
|
457
|
+
}
|
|
458
|
+
return entries;
|
|
459
|
+
}
|
|
460
|
+
for (const [moduleName, value] of Object.entries(asRecord(raw))) {
|
|
461
|
+
entries.push({ moduleName, descriptor: parseModuleEntry(moduleName, value, diagnostics) });
|
|
462
|
+
}
|
|
463
|
+
return entries;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function parsePluginReferences(raw: JsonValue | undefined, diagnostics: string[]): PluginReference[] {
|
|
467
|
+
if (!Array.isArray(raw)) return [];
|
|
468
|
+
const out: PluginReference[] = [];
|
|
469
|
+
for (const item of raw) {
|
|
470
|
+
const record = asRecord(item);
|
|
471
|
+
const name = asString(record.Name);
|
|
472
|
+
if (!name) {
|
|
473
|
+
diagnostics.push("plugin reference without a Name is ignored by the engine");
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
for (const key of Object.keys(record)) {
|
|
477
|
+
if (!PLUGIN_REF_FIELDS.has(key)) diagnostics.push(`plugin reference ${name}: field "${key}" is not serialized by FPluginReferenceDescriptor`);
|
|
478
|
+
}
|
|
479
|
+
if (typeof record.Enabled !== "boolean") {
|
|
480
|
+
// PluginReferenceDescriptor.cpp:138 — UE 5.8 fails the whole `.uproject` load without it.
|
|
481
|
+
diagnostics.push(`plugin reference ${name}: no boolean "Enabled" field (UE 5.8 rejects the reference: "Plugin references must have an 'Enabled' field")`);
|
|
482
|
+
}
|
|
483
|
+
out.push({
|
|
484
|
+
name,
|
|
485
|
+
enabled: asBoolean(record.Enabled, true),
|
|
486
|
+
optional: asBoolean(record.Optional),
|
|
487
|
+
activate: asBoolean(record.Activate),
|
|
488
|
+
version: asNumber(record.Version),
|
|
489
|
+
semVersion: asString(record.SemVersion),
|
|
490
|
+
supportedTargetPlatforms: asStringArray(record.SupportedTargetPlatforms),
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
return out;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function unknownFieldsIn(record: Record<string, JsonValue>, known: Set<string>): string[] {
|
|
497
|
+
return Object.keys(record).filter((key) => !known.has(key));
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Parse a `.uproject`. `errors` (parse failure) is reported through `diagnostics` so callers
|
|
502
|
+
* always get a structure back, mirroring how the engine treats a malformed descriptor as
|
|
503
|
+
* "no descriptor" rather than a partial one.
|
|
504
|
+
*/
|
|
505
|
+
export function parseUproject(path: string, fs: FsAdapter): UprojectDescriptor {
|
|
506
|
+
const diagnostics: string[] = [];
|
|
507
|
+
const text = readFileSafe(path, fs);
|
|
508
|
+
if (text === undefined) {
|
|
509
|
+
return {
|
|
510
|
+
path,
|
|
511
|
+
modules: [],
|
|
512
|
+
plugins: [],
|
|
513
|
+
targetPlatforms: [],
|
|
514
|
+
additionalPluginDirectories: [],
|
|
515
|
+
additionalRootDirectories: [],
|
|
516
|
+
enterprise: false,
|
|
517
|
+
disableEnginePluginsByDefault: false,
|
|
518
|
+
knownFields: [],
|
|
519
|
+
unknownFields: [],
|
|
520
|
+
diagnostics: [`cannot read ${path}`],
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
const { value, errors } = parseJsoncLenient<Record<string, JsonValue>>(text);
|
|
524
|
+
if (errors.length > 0) diagnostics.push(`jsonc: ${errors.join(", ")} (engine-side load would fail)`);
|
|
525
|
+
const record = asRecord(value);
|
|
526
|
+
const plugins = parsePluginReferences(record.Plugins, diagnostics);
|
|
527
|
+
for (const key of Object.keys(record)) {
|
|
528
|
+
if (!UPROJECT_FIELDS.has(key)) diagnostics.push(`.uproject field "${key}" is not serialized by FProjectDescriptor`);
|
|
529
|
+
}
|
|
530
|
+
return {
|
|
531
|
+
path,
|
|
532
|
+
engineAssociation: asString(record.EngineAssociation),
|
|
533
|
+
category: asString(record.Category),
|
|
534
|
+
description: asString(record.Description),
|
|
535
|
+
enterprise: asBoolean(record.Enterprise),
|
|
536
|
+
disableEnginePluginsByDefault: asBoolean(record.DisableEnginePluginsByDefault),
|
|
537
|
+
modules: parseModuleMap(record.Modules, diagnostics),
|
|
538
|
+
plugins,
|
|
539
|
+
targetPlatforms: asStringArray(record.TargetPlatforms),
|
|
540
|
+
additionalPluginDirectories: asStringArray(record.AdditionalPluginDirectories),
|
|
541
|
+
additionalRootDirectories: asStringArray(record.AdditionalRootDirectories),
|
|
542
|
+
knownFields: Object.keys(record).filter((key) => UPROJECT_FIELDS.has(key)),
|
|
543
|
+
unknownFields: unknownFieldsIn(record, UPROJECT_FIELDS),
|
|
544
|
+
diagnostics,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** Parse a `.uplugin`. `name` defaults to the file stem, which is what the engine uses as the plugin name. */
|
|
549
|
+
export function parseUplugin(path: string, fs: FsAdapter, options: { name?: string } = {}): UpluginDescriptor {
|
|
550
|
+
const diagnostics: string[] = [];
|
|
551
|
+
const text = readFileSafe(path, fs);
|
|
552
|
+
const stem = (path.split("/").pop() ?? "").replace(/\.uplugin$/i, "");
|
|
553
|
+
const base: UpluginDescriptor = {
|
|
554
|
+
path,
|
|
555
|
+
name: options.name ?? stem,
|
|
556
|
+
enabledByDefault: false,
|
|
557
|
+
canContainContent: false,
|
|
558
|
+
canContainVerse: false,
|
|
559
|
+
noCode: false,
|
|
560
|
+
isBetaVersion: false,
|
|
561
|
+
isExperimentalVersion: false,
|
|
562
|
+
installed: false,
|
|
563
|
+
requiresBuildPlatform: false,
|
|
564
|
+
isHidden: false,
|
|
565
|
+
isSealed: false,
|
|
566
|
+
explicitlyLoaded: false,
|
|
567
|
+
hasExplicitPlatforms: false,
|
|
568
|
+
isPluginExtension: false,
|
|
569
|
+
supportedTargetPlatforms: [],
|
|
570
|
+
supportedPrograms: [],
|
|
571
|
+
platformAllowList: [],
|
|
572
|
+
platformDenyList: [],
|
|
573
|
+
targetAllowList: [],
|
|
574
|
+
targetDenyList: [],
|
|
575
|
+
modules: [],
|
|
576
|
+
plugins: [],
|
|
577
|
+
knownFields: [],
|
|
578
|
+
unknownFields: [],
|
|
579
|
+
diagnostics,
|
|
580
|
+
};
|
|
581
|
+
if (text === undefined) {
|
|
582
|
+
diagnostics.push(`cannot read ${path}`);
|
|
583
|
+
return base;
|
|
584
|
+
}
|
|
585
|
+
const { value, errors } = parseJsoncLenient<Record<string, JsonValue>>(text);
|
|
586
|
+
if (errors.length > 0) diagnostics.push(`jsonc: ${errors.join(", ")} (engine-side load would fail)`);
|
|
587
|
+
const record = asRecord(value);
|
|
588
|
+
base.versionRaw = asNumber(record.Version) ?? asNumber(record.FileVersion);
|
|
589
|
+
base.version = decodeFileVersion(base.versionRaw);
|
|
590
|
+
base.semVersion = asString(record.SemVersion);
|
|
591
|
+
base.versionName = asString(record.VersionName);
|
|
592
|
+
base.engineVersion = parseEngineVersion(asString(record.EngineVersion));
|
|
593
|
+
base.friendlyName = asString(record.FriendlyName);
|
|
594
|
+
base.category = asString(record.Category) ?? asString(record.CategoryPath);
|
|
595
|
+
base.description = asString(record.Description);
|
|
596
|
+
base.enabledByDefault = asBoolean(record.EnabledByDefault);
|
|
597
|
+
base.canContainContent = asBoolean(record.CanContainContent) || asBoolean(record.bCanContainContent);
|
|
598
|
+
base.canContainVerse = asBoolean(record.CanContainVerse);
|
|
599
|
+
base.noCode = asBoolean(record.NoCode);
|
|
600
|
+
base.isBetaVersion = asBoolean(record.IsBetaVersion);
|
|
601
|
+
base.isExperimentalVersion = asBoolean(record.IsExperimentalVersion);
|
|
602
|
+
base.installed = asBoolean(record.Installed);
|
|
603
|
+
base.requiresBuildPlatform = asBoolean(record.RequiresBuildPlatform);
|
|
604
|
+
base.isHidden = asBoolean(record.Hidden);
|
|
605
|
+
base.isSealed = asBoolean(record.Sealed);
|
|
606
|
+
base.explicitlyLoaded = asBoolean(record.ExplicitlyLoaded);
|
|
607
|
+
base.hasExplicitPlatforms = asBoolean(record.HasExplicitPlatforms);
|
|
608
|
+
base.isPluginExtension = asBoolean(record.bIsPluginExtension);
|
|
609
|
+
base.createdBy = asString(record.CreatedBy);
|
|
610
|
+
base.docsUrl = asString(record.DocsURL);
|
|
611
|
+
base.marketplaceUrl = asString(record.MarketplaceURL);
|
|
612
|
+
base.supportUrl = asString(record.SupportURL);
|
|
613
|
+
base.supportedTargetPlatforms = asStringArray(record.SupportedTargetPlatforms);
|
|
614
|
+
base.supportedPrograms = asStringArray(record.SupportedPrograms);
|
|
615
|
+
base.platformAllowList = asStringArray(record.PlatformAllowList);
|
|
616
|
+
base.platformDenyList = asStringArray(record.PlatformDenyList);
|
|
617
|
+
base.targetAllowList = asStringArray(record.TargetAllowList);
|
|
618
|
+
base.targetDenyList = asStringArray(record.TargetDenyList);
|
|
619
|
+
base.modules = parseModuleMap(record.Modules, diagnostics);
|
|
620
|
+
base.plugins = parsePluginReferences(record.Plugins, diagnostics);
|
|
621
|
+
base.knownFields = Object.keys(record).filter((key) => UPLUGIN_FIELDS.has(key));
|
|
622
|
+
base.unknownFields = unknownFieldsIn(record, UPLUGIN_FIELDS);
|
|
623
|
+
if (base.modules.length === 0) diagnostics.push("no Modules entries: the plugin contributes no modules");
|
|
624
|
+
return base;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** `Major.Minor` of an `FEngineVersion`, the unit `.uproject` associations are keyed by. */
|
|
628
|
+
export function versionToMajorMinor(version: EngineVersion | undefined): string | undefined {
|
|
629
|
+
if (!version) return undefined;
|
|
630
|
+
return `${version.major}.${version.minor}`;
|
|
631
|
+
}
|