Polyphase Game Engine
Loading...
Searching...
No Matches
NativeAddonManager.h
Go to the documentation of this file.
1#pragma once
2
8#if EDITOR
9
13
14#include <string>
15#include <unordered_map>
16#include <unordered_set>
17#include <vector>
18#include <thread>
19#include <atomic>
20#include <mutex>
21#include <memory>
22
26struct NativeAddonCreateInfo
27{
28 std::string mName; // Display name (e.g., "My Addon")
29 std::string mId; // Internal ID (e.g., "my-addon", auto-generated from name if empty)
30 std::string mAuthor;
31 std::string mDescription;
32 std::string mVersion = "1.0.0";
33 NativeAddonTarget mTarget = NativeAddonTarget::EngineAndEditor;
34 std::string mBinaryName; // Auto-generated from ID if empty
35};
36
40struct NativeAddonPackageOptions
41{
42 std::string mAddonId;
43 bool mIncludeSource = true;
44 bool mIncludeAssets = true;
45 bool mIncludeScripts = true;
46 bool mIncludeThumbnail = true;
47 std::string mOutputPath; // Full path to output zip file
48};
49
53struct NativeAddonState
54{
55 std::string mAddonId;
56 std::string mSourcePath; // Path to addon source (local Packages/ or cache)
57 std::string mLoadedPath; // Path to loaded DLL/SO
58 void* mModuleHandle = nullptr;
59 // Cached module image range, computed once after a successful module load.
60 // Used by FindAddonIdForFactory to reverse-map a Factory* (which lives at a
61 // global static address inside the addon's image) back to its owning addon
62 // so the editor can plant addon-registered nodes under "Addons / <addonId>".
63 // mModuleEnd is only meaningful on Windows (image-extent known from
64 // GetModuleInformation); on Linux only mModuleBase is populated, and the
65 // forward lookup uses dladdr().dli_fbase == mModuleBase.
66 uintptr_t mModuleBase = 0;
67 uintptr_t mModuleEnd = 0;
68 std::string mFingerprint; // Hash for rebuild detection
69 // Shadow-copy directory created at load time so the engine never holds an
70 // OS-level LoadLibrary lock on the build-output tree (Intermediate/Plugins).
71 // Cleared on successful UnloadNativeAddon; if delete fails (mspdbsrv lag),
72 // the path is pushed to NativeAddonManager::mPendingShadowDeletes and
73 // retried later / swept on next editor launch.
74 std::string mShadowDir;
75
76 // Build state
77 bool mBuildInProgress = false;
78 bool mBuildSucceeded = false;
79 std::string mBuildLog;
80 std::string mBuildError;
81 // "How to fix" text paired with mBuildError. Filled by the build preflight
82 // or ClassifyBuildFailure and rendered by the native addon problem modal.
83 std::string mFixHint;
84 // Optional download page the fix points at (Vulkan SDK, Visual Studio).
85 std::string mFixUrl;
86 // Dependency addon ids declared in package.json that have no
87 // <project>/Packages/<id>/package.json on disk. Non-empty blocks build and
88 // load of this addon until the user installs them.
89 std::vector<std::string> mMissingDependencies;
90
91 // Plugin descriptor (after load)
92 PolyphasePluginDesc mDesc = {};
93 bool mDescValid = false;
94
95 // Native metadata from package.json
96 NativeModuleMetadata mNativeMetadata;
97
98 // Shared metadata from package.json (name/version/dependencies/onInstall/etc.)
99 ContentMetadata mContentMetadata;
100
101 // Runtime resolve/load status
102 NativeAddonResolveMode mActiveResolveMode = NativeAddonResolveMode::Source;
103 bool mLoadedFromBinary = false;
104 std::string mBinaryStatus;
105
106 // UUIDs of assets that PurgeAssetsFromModule unloaded during the most
107 // recent UnloadNativeAddon. LoadNativeAddon drains this on its next
108 // successful load, calling LoadAsset on each so an addon-typed asset
109 // that was loaded before reload comes back loaded after — without this,
110 // the post-reload stub has mAsset=null and SaveAsset becomes a no-op.
111 std::vector<uint64_t> mPurgedAssetUuids;
112
113 // True once the user has dismissed the build-failure modal entry for the
114 // most recent failure. Reset to false whenever a fresh build attempt starts
115 // so a re-failure surfaces again.
116 bool mBuildFailureAcknowledged = false;
117};
118
128class NativeAddonManager
129{
130public:
131 static void Create();
132 static void Destroy();
133 static NativeAddonManager* Get();
134
135 // ===== Discovery =====
136
140 void DiscoverNativeAddons();
141
145 std::vector<std::string> GetDiscoveredAddonIds() const;
146
147 // ===== Build Operations =====
148
156 bool BuildNativeAddon(const std::string& addonId, std::string& outError);
157
164 std::string ComputeFingerprint(const std::string& addonId);
165
172 bool NeedsBuild(const std::string& addonId);
173
174private:
176 void WriteAddonBuildMeta(const std::string& outputPath,
177 const std::string& fingerprint);
178
180 NativeAddonResolveMode ResolveModeForAddon(const std::string& addonId) const;
181
183 bool ResolveBinaryModulePath(const std::string& addonId, std::string& outModulePath, std::string& outStatus, std::string& outError);
184
186 bool IsBinaryDescriptorCompatible(const NativeBinaryDescriptor& descriptor, const NativeAddonState& state) const;
187
191 bool MetaIndicatesRebuildNeeded(const std::string& outputPath) const;
192public:
193
194 // ===== Load/Unload Operations =====
195
203 bool LoadNativeAddon(const std::string& addonId, std::string& outError);
204
211 bool UnloadNativeAddon(const std::string& addonId);
212
220 bool ReloadNativeAddon(const std::string& addonId, std::string& outError);
221
227 void ReloadAllNativeAddons();
228
238 void UnloadAllNativeAddons();
239
262 bool RecoverFromStuckAddons(const char* reason);
263
264 // Force Rebuild was removed in favour of the per-addon Reload button +
265 // the per-project chokepoint. To force a fresh compile of all addons,
266 // call ReloadNativeAddonsWithProjectRestart({}, /*forceRebuild*/true, ...).
267
268 // ===== Async build state (drives the progress modal) =====
269
273 void TickAsyncBuilds();
274
276 bool IsBuildingAsync() const;
277
279 int GetAsyncBuildTotal() const;
280
282 int GetAsyncBuildIndex() const;
283
285 std::string GetAsyncBuildAddonId() const;
286
289 std::string GetAsyncBuildOutput() const;
290
291 // ===== Build-blocked state (locked intermediate files) =====
292 //
293 // Before a build runs, BuildNativeAddon / StartNextQueuedBuild sweep the
294 // addon's intermediate fingerprint dir and try to delete every file. If
295 // any file is locked (most commonly the .pdb held open by mspdbsrv.exe
296 // across DLL unload, producing LNK1201 at link time), the sweep records
297 // the offending paths and the build is paused. The editor surfaces a
298 // modal listing the locked files with Retry / Cancel — Retry re-sweeps
299 // and resumes if clean, Cancel abandons the operation.
300 struct BuildBlocked
301 {
302 bool mActive = false;
303 std::string mAddonId;
304 std::vector<std::string> mLockedFiles;
305 // Absolute path to <project>/Intermediate/Plugins/<addonId>/ — the
306 // simplest manual fix is to delete this entire directory. The modal
307 // surfaces this as a copy-paste shell command.
308 std::string mIntermediateDir;
309 // Number of times the user has clicked Retry on this block. Reset
310 // when a fresh block is raised by a different addon or after the
311 // build succeeds. The modal uses this to escalate the recovery UX
312 // (Tier 1 → Tier 2 → Tier 3) once auto-kill-and-retry plainly isn't
313 // unsticking the lock.
314 int mRetryCount = 0;
315 };
316 bool IsBuildBlocked() const { return mBlocked.mActive; }
317 const BuildBlocked& GetBuildBlocked() const { return mBlocked; }
321 void RetryBlockedBuild();
323 void CancelBlockedBuild();
324
325 // ===== Build-failure surface =====
326 //
327 // Aggregates per-addon compile/link failures across both the sync
328 // BuildNativeAddon path and the async TickAsyncBuilds path. Drives the
329 // build-failure modal so users don't have to scan the log to know which
330 // addon broke. An entry stays "active" while:
331 // state.mBuildSucceeded == false &&
332 // !state.mBuildError.empty() &&
333 // !state.mBuildInProgress &&
334 // !state.mBuildFailureAcknowledged
335 // and is implicitly cleared the next time the addon starts a build.
336 struct BuildFailureEntry
337 {
338 std::string mAddonId;
339 std::string mError; // High-level message (exit code, "Build failed" etc.)
340 std::string mLog; // Captured stdout/stderr from the compiler/linker
341 std::string mFixHint; // How to fix, from the preflight or ClassifyBuildFailure
342 std::string mFixUrl; // Download page for the fix; empty when none applies
343 std::vector<std::string> mMissingDependencies; // Deps installable from the modal
344 };
345 std::vector<BuildFailureEntry> GetActiveBuildFailures() const;
346 bool HasUnacknowledgedBuildFailures() const;
347 void DismissBuildFailure(const std::string& addonId);
348 void DismissAllBuildFailures();
351 void RetryFailedBuild(const std::string& addonId);
352
353 // ===== Build environment =====
354
360 static std::string ResolveVulkanIncludeDir();
361
362 struct BuildFailureHint
363 {
364 std::string mText; // What to do; empty when the failure is not recognised
365 std::string mUrl; // Download page, may be empty
366 std::string mDependencyId; // Addon id to install, may be empty
367 };
369 static BuildFailureHint ClassifyBuildFailure(const std::string& log);
370
375 bool DiscoverAddonPackage(const std::string& addonId);
376
381 std::vector<std::string> RefreshMissingDependencies();
382
383 // ===== Project-restart reload chokepoint =====
384 //
385 // Native addon reload is unsafe when scenes are open — live nodes hold
386 // vtable pointers into the addon's mapped DLL pages and any unload
387 // invalidates them, plus Node factories get stripped from the global
388 // registry so a later scene reopen falls back to Node3D and silently
389 // corrupts the in-memory tree on save. The fix is to close the project
390 // entirely (saving dirty scenes first per user choice), unload every
391 // addon, rebuild, then reopen the project from disk so factories,
392 // assets, and scenes all rehydrate cleanly.
393 //
394 // The flow is staged across frames because the rebuild runs async on a
395 // worker thread. Phase advances:
396 // None
397 // -> AwaitingConfirm (one-shot confirm modal)
398 // -> AwaitingDirty (per-scene Save/Discard/Cancel — one popup at
399 // a time, advancing through mDirtyScenes)
400 // -> Building (project closed, builds in flight; advanced by
401 // TickAsyncBuilds when the queue drains)
402 // -> Reopening (synchronous OpenProject + scene restore; only
403 // held briefly for telemetry, then cleared)
404 // -> None
405 enum class ProjectRestartPhase
406 {
407 None,
408 AwaitingConfirm,
409 AwaitingDirty,
410 Building,
411 Reopening,
412 };
413
414 struct ProjectRestart
415 {
416 ProjectRestartPhase mPhase = ProjectRestartPhase::None;
417
418 // Addons being rebuilt. Empty = all installed enabled native addons
419 // (unless this is a removal, where nothing is rebuilt).
420 std::vector<std::string> mTargetAddons;
421 // forceRebuild=true wipes each target's fingerprint dir before
422 // building so NeedsBuild() returns true even for an unchanged source.
423 bool mForceRebuild = false;
424 std::string mReason; // user-facing modal copy
425 // Removal flow: addons to unload + uninstall inside the close window,
426 // dependents first. No builds are queued; the project reopens with
427 // the packages gone. mIsRemoval switches the modal copy.
428 std::vector<std::string> mRemoveAddons;
429 bool mIsRemoval = false;
430
431 // Snapshot — captured at restart entry, restored after OpenProject.
432 std::string mProjectPath;
433 std::vector<std::string> mOpenSceneNames; // names of edit scenes to reopen
434 std::string mActiveSceneName; // active edit scene at snapshot
435
436 // Per-scene dirty queue. Walked one-at-a-time during AwaitingDirty.
437 std::vector<std::string> mDirtyScenes;
438 int32_t mDirtyCursor = 0;
439 };
440
441 bool IsProjectRestartActive() const { return mRestart.mPhase != ProjectRestartPhase::None; }
442 const ProjectRestart& GetProjectRestart() const { return mRestart; }
443
448 void ReloadNativeAddonsWithProjectRestart(const std::vector<std::string>& addonIds,
449 bool forceRebuild,
450 const char* reason);
451
459 bool RemoveNativeAddonsWithProjectRestart(const std::vector<std::string>& removeIds,
460 const char* reason);
461
465 void ForgetAddon(const std::string& addonId);
466
467 // Modal callbacks. Called from the EditorImgui modal renderers when the
468 // user clicks the corresponding button. Public because the modal lives
469 // outside this class.
470 void ProjectRestartConfirm(); // [Continue] on confirm modal
471 void ProjectRestartCancel(); // [Cancel] on confirm modal — abort whole flow
472 void ProjectRestartDirtySave(); // [Save] for the current dirty scene
473 void ProjectRestartDirtyDiscard(); // [Discard] for the current dirty scene
474 void ProjectRestartDirtyCancel(); // [Cancel] in dirty prompt — abort whole flow
475
484 void TickAllPlugins(float deltaTime);
485
494 void TickEditorAllPlugins(float deltaTime);
495
499 void CallOnEditorPreInit();
500
504 void CallOnEditorReady();
505
506 // ===== State Queries =====
507
514 const NativeAddonState* GetState(const std::string& addonId) const;
515
519 bool IsLoaded(const std::string& addonId) const;
520
527 std::string GetAddonSourcePath(const std::string& addonId) const;
528
541 std::string FindAddonRootForBuildTarget(const std::string& buildTargetId) const;
542
546 std::vector<NativeAddonState> GetEngineAddons() const;
547
551 PolyphaseEngineAPI* GetEngineAPI() { return &mEngineAPI; }
552
565 const char* FindAddonIdForFactory(const void* factoryPtr) const;
566
567 // ===== Creation and Packaging =====
568
583 bool CreateNativeAddon(const NativeAddonCreateInfo& info, std::string& outError, std::string* outPath = nullptr);
584
597 bool CreateNativeAddonAtPath(const NativeAddonCreateInfo& info, const std::string& targetDir,
598 std::string& outError, std::string* outPath = nullptr);
599
609 bool PackageNativeAddon(const NativeAddonPackageOptions& options, std::string& outError);
610
619 bool GenerateIDEConfig(const std::string& addonPath);
620
624 std::vector<std::string> GetLocalPackageIds() const;
625
634 static bool GenerateAddonIncludesManifest();
635
643 static bool LoadAddonIncludesManifest(std::vector<std::string>& outIncludePaths,
644 std::vector<std::string>& outDefines);
645
646private:
647 static NativeAddonManager* sInstance;
648 NativeAddonManager();
649 ~NativeAddonManager();
650
651 // Discovery helpers
652 void ScanLocalPackages();
653 void ScanInstalledAddons();
654 bool ParsePackageJson(const std::string& path, NativeModuleMetadata& outMetadata, ContentMetadata* outContent = nullptr);
655
659 std::vector<std::string> GetLoadOrder() const;
660
661 // Cached topo order produced by the most recent ResolveAll() during discovery.
662 std::vector<std::string> mCachedLoadOrder;
663
664 // Build helpers
665 std::string GetIntermediateDir(const std::string& addonId);
666 std::string GetOutputPath(const std::string& addonId, const std::string& fingerprint);
671 bool RunBuildPreflight(const std::string& addonId, std::string& outError,
672 BuildFailureHint& outHint);
673
674 bool GenerateBuildScript(const std::string& addonId, const std::string& outputDir,
675 const std::string& outputPath, std::string& outScriptPath);
676 std::vector<std::string> GatherSourceFiles(const std::string& sourceDir);
677
684 std::vector<std::string> TryClearAddonIntermediates(const std::string& addonId);
685
686 // Engine API setup
687 void InitializeEngineAPI();
688
689 // Creation helpers
690 std::string GenerateIdFromName(const std::string& name);
691 bool WriteTemplateSourceFile(const std::string& path, const std::string& addonName,
692 const std::string& binaryName);
693 bool WritePackageJson(const std::string& path, const NativeAddonCreateInfo& info);
694 bool WriteVSCodeConfig(const std::string& addonPath);
695 bool WriteCMakeLists(const std::string& addonPath, const std::string& binaryName);
696 bool WriteVSProject(const std::string& addonPath, const std::string& addonName,
697 const std::string& binaryName);
698
699 std::unordered_map<std::string, NativeAddonState> mStates;
700 PolyphaseEngineAPI mEngineAPI;
701
702 // ----- Shadow-copy load cache -----
703 //
704 // The editor LoadLibrary's an addon DLL from a per-launch cache dir rather
705 // than from Intermediate/Plugins/<addon>/<fp>/ directly. That keeps the
706 // build-output tree free of OS file locks so the user can wipe / rebuild
707 // intermediates while the editor is running. See NativeAddonManager.cpp
708 // GetShadowCopyPath / SweepStaleShadowCopies for the layout.
709 std::string mShadowSessionId; // PID-derived, set in ctor
710 std::vector<std::string> mPendingShadowDeletes; // shadow dirs to retry on Tick
711 // Retry pacing for mPendingShadowDeletes: one rmdir sweep every few
712 // seconds, a bounded number of times, then leave the leftovers to the
713 // next launch's SweepStaleShadowCopies. Without this a .pdb pinned by a
714 // debugger spams an rmdir per frame for the rest of the session.
715 float mShadowDeleteRetryTimer = 0.0f;
716 int mShadowDeleteRetryCount = 0;
717 void RetryPendingShadowDeletes(float deltaTime);
718
719 std::string GetShadowCopyDir(const std::string& addonId,
720 const std::string& fingerprint);
721 bool StageShadowCopy(const std::string& sourceModulePath,
722 const std::string& shadowDir,
723 std::string& outShadowModulePath,
724 std::string& outError);
725 void TryDeleteShadowDir(const std::string& dir);
726 void SweepStaleShadowCopies();
727
728 // ----- Async build queue -----
729 //
730 // One worker thread shells out to build.bat / build.sh per addon. The
731 // main thread polls completion in TickAsyncBuilds(), runs the post-
732 // build steps (write meta, MOD_Load, register types), and starts the
733 // next queued item. This keeps the editor interactive while addons
734 // compile, especially during multi-addon Force Rebuild.
735 struct AsyncAddonBuild
736 {
737 std::string addonId;
738 std::string scriptPath;
739 std::string outputPath;
740 std::string fingerprint;
741
742 std::thread thread;
743 std::atomic<bool> complete{false};
744 std::atomic<int> exitCode{0};
745
746 mutable std::mutex outputMutex;
747 std::string output; // guarded by outputMutex
748 };
749
750 std::unique_ptr<AsyncAddonBuild> mActiveBuild;
751 std::vector<std::string> mBuildQueue;
752 int mBuildQueueTotal = 0;
753 int mBuildQueueIndex = 0; // 1-based, advanced when a build starts
754
755 // Set when a pre-build sweep finds locked files in the intermediate dir.
756 BuildBlocked mBlocked;
757
758 // Carries the retry counter from one RetryBlockedBuild into the next
759 // BuildBlocked the (synchronous or async) build path raises. Reset to 0
760 // after the value lands in mBlocked.mRetryCount. Without this, the
761 // counter would reset every time we transiently clear mBlocked at the
762 // top of RetryBlockedBuild — the modal would never see "this is the
763 // user's third try" and never escalate to Tier 2.
764 int mPendingRetryCount = 0;
765
766 // Project-restart state machine. See ProjectRestartPhase for the flow.
767 ProjectRestart mRestart;
768
769 // One-shot per-addon override: addon IDs in this set get their next
770 // LoadNativeAddon() invocation treated as resolveMode=source even when
771 // package.json says "binary". Set when the user clicks Reload Native
772 // Addons on a binary-mode addon — Reload means "recompile my local
773 // source", not "redownload the published binary". Consumed (erased)
774 // on first read so the override doesn't persist beyond a single load.
775 std::unordered_set<std::string> mForceSourceForNextLoad;
776
777 // Internal helpers
778 void StartNextQueuedBuild();
779 void FinalizeAsyncBuild(AsyncAddonBuild& job, bool success);
780 bool LoadNativeAddonAfterBuild(const std::string& addonId, std::string& outError);
781
782 // Project-restart helpers
783 bool ProjectRestartStage(const std::vector<std::string>& addonIds,
784 bool forceRebuild, const char* reason); // guards + snapshot + dirty queue → AwaitingConfirm
785 void ProjectRestartBeginClose(); // dirty queue exhausted → close project + enqueue rebuilds
786 void ProjectRestartOnBuildsDone(); // called from TickAsyncBuilds when in Building phase
787 void ProjectRestartReset(); // clear state back to Phase::None
788};
789
790#endif // EDITOR
Engine API exposed to native addon plugins.
Stable C ABI header for native addon plugins.
Engine API provided to plugins during OnLoad.
Definition PolyphaseEngineAPI.h:32
Plugin descriptor returned by PolyphasePlugin_GetDesc.
Definition PolyphasePluginAPI.h:75