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
82 // Plugin descriptor (after load)
83 PolyphasePluginDesc mDesc = {};
84 bool mDescValid = false;
85
86 // Native metadata from package.json
87 NativeModuleMetadata mNativeMetadata;
88
89 // Shared metadata from package.json (name/version/dependencies/onInstall/etc.)
90 ContentMetadata mContentMetadata;
91
92 // Runtime resolve/load status
93 NativeAddonResolveMode mActiveResolveMode = NativeAddonResolveMode::Source;
94 bool mLoadedFromBinary = false;
95 std::string mBinaryStatus;
96
97 // UUIDs of assets that PurgeAssetsFromModule unloaded during the most
98 // recent UnloadNativeAddon. LoadNativeAddon drains this on its next
99 // successful load, calling LoadAsset on each so an addon-typed asset
100 // that was loaded before reload comes back loaded after — without this,
101 // the post-reload stub has mAsset=null and SaveAsset becomes a no-op.
102 std::vector<uint64_t> mPurgedAssetUuids;
103
104 // True once the user has dismissed the build-failure modal entry for the
105 // most recent failure. Reset to false whenever a fresh build attempt starts
106 // so a re-failure surfaces again.
107 bool mBuildFailureAcknowledged = false;
108};
109
119class NativeAddonManager
120{
121public:
122 static void Create();
123 static void Destroy();
124 static NativeAddonManager* Get();
125
126 // ===== Discovery =====
127
131 void DiscoverNativeAddons();
132
136 std::vector<std::string> GetDiscoveredAddonIds() const;
137
138 // ===== Build Operations =====
139
147 bool BuildNativeAddon(const std::string& addonId, std::string& outError);
148
155 std::string ComputeFingerprint(const std::string& addonId);
156
163 bool NeedsBuild(const std::string& addonId);
164
165private:
167 void WriteAddonBuildMeta(const std::string& outputPath,
168 const std::string& fingerprint);
169
171 NativeAddonResolveMode ResolveModeForAddon(const std::string& addonId) const;
172
174 bool ResolveBinaryModulePath(const std::string& addonId, std::string& outModulePath, std::string& outStatus, std::string& outError);
175
177 bool IsBinaryDescriptorCompatible(const NativeBinaryDescriptor& descriptor, const NativeAddonState& state) const;
178
182 bool MetaIndicatesRebuildNeeded(const std::string& outputPath) const;
183public:
184
185 // ===== Load/Unload Operations =====
186
194 bool LoadNativeAddon(const std::string& addonId, std::string& outError);
195
202 bool UnloadNativeAddon(const std::string& addonId);
203
211 bool ReloadNativeAddon(const std::string& addonId, std::string& outError);
212
218 void ReloadAllNativeAddons();
219
229 void UnloadAllNativeAddons();
230
253 bool RecoverFromStuckAddons(const char* reason);
254
255 // Force Rebuild was removed in favour of the per-addon Reload button +
256 // the per-project chokepoint. To force a fresh compile of all addons,
257 // call ReloadNativeAddonsWithProjectRestart({}, /*forceRebuild*/true, ...).
258
259 // ===== Async build state (drives the progress modal) =====
260
264 void TickAsyncBuilds();
265
267 bool IsBuildingAsync() const;
268
270 int GetAsyncBuildTotal() const;
271
273 int GetAsyncBuildIndex() const;
274
276 std::string GetAsyncBuildAddonId() const;
277
280 std::string GetAsyncBuildOutput() const;
281
282 // ===== Build-blocked state (locked intermediate files) =====
283 //
284 // Before a build runs, BuildNativeAddon / StartNextQueuedBuild sweep the
285 // addon's intermediate fingerprint dir and try to delete every file. If
286 // any file is locked (most commonly the .pdb held open by mspdbsrv.exe
287 // across DLL unload, producing LNK1201 at link time), the sweep records
288 // the offending paths and the build is paused. The editor surfaces a
289 // modal listing the locked files with Retry / Cancel — Retry re-sweeps
290 // and resumes if clean, Cancel abandons the operation.
291 struct BuildBlocked
292 {
293 bool mActive = false;
294 std::string mAddonId;
295 std::vector<std::string> mLockedFiles;
296 // Absolute path to <project>/Intermediate/Plugins/<addonId>/ — the
297 // simplest manual fix is to delete this entire directory. The modal
298 // surfaces this as a copy-paste shell command.
299 std::string mIntermediateDir;
300 // Number of times the user has clicked Retry on this block. Reset
301 // when a fresh block is raised by a different addon or after the
302 // build succeeds. The modal uses this to escalate the recovery UX
303 // (Tier 1 → Tier 2 → Tier 3) once auto-kill-and-retry plainly isn't
304 // unsticking the lock.
305 int mRetryCount = 0;
306 };
307 bool IsBuildBlocked() const { return mBlocked.mActive; }
308 const BuildBlocked& GetBuildBlocked() const { return mBlocked; }
312 void RetryBlockedBuild();
314 void CancelBlockedBuild();
315
316 // ===== Build-failure surface =====
317 //
318 // Aggregates per-addon compile/link failures across both the sync
319 // BuildNativeAddon path and the async TickAsyncBuilds path. Drives the
320 // build-failure modal so users don't have to scan the log to know which
321 // addon broke. An entry stays "active" while:
322 // state.mBuildSucceeded == false &&
323 // !state.mBuildError.empty() &&
324 // !state.mBuildInProgress &&
325 // !state.mBuildFailureAcknowledged
326 // and is implicitly cleared the next time the addon starts a build.
327 struct BuildFailureEntry
328 {
329 std::string mAddonId;
330 std::string mError; // High-level message (exit code, "Build failed" etc.)
331 std::string mLog; // Captured stdout/stderr from the compiler/linker
332 };
333 std::vector<BuildFailureEntry> GetActiveBuildFailures() const;
334 bool HasUnacknowledgedBuildFailures() const;
335 void DismissBuildFailure(const std::string& addonId);
336 void DismissAllBuildFailures();
339 void RetryFailedBuild(const std::string& addonId);
340
341 // ===== Project-restart reload chokepoint =====
342 //
343 // Native addon reload is unsafe when scenes are open — live nodes hold
344 // vtable pointers into the addon's mapped DLL pages and any unload
345 // invalidates them, plus Node factories get stripped from the global
346 // registry so a later scene reopen falls back to Node3D and silently
347 // corrupts the in-memory tree on save. The fix is to close the project
348 // entirely (saving dirty scenes first per user choice), unload every
349 // addon, rebuild, then reopen the project from disk so factories,
350 // assets, and scenes all rehydrate cleanly.
351 //
352 // The flow is staged across frames because the rebuild runs async on a
353 // worker thread. Phase advances:
354 // None
355 // -> AwaitingConfirm (one-shot confirm modal)
356 // -> AwaitingDirty (per-scene Save/Discard/Cancel — one popup at
357 // a time, advancing through mDirtyScenes)
358 // -> Building (project closed, builds in flight; advanced by
359 // TickAsyncBuilds when the queue drains)
360 // -> Reopening (synchronous OpenProject + scene restore; only
361 // held briefly for telemetry, then cleared)
362 // -> None
363 enum class ProjectRestartPhase
364 {
365 None,
366 AwaitingConfirm,
367 AwaitingDirty,
368 Building,
369 Reopening,
370 };
371
372 struct ProjectRestart
373 {
374 ProjectRestartPhase mPhase = ProjectRestartPhase::None;
375
376 // Addons being rebuilt. Empty = all installed enabled native addons.
377 std::vector<std::string> mTargetAddons;
378 // forceRebuild=true wipes each target's fingerprint dir before
379 // building so NeedsBuild() returns true even for an unchanged source.
380 bool mForceRebuild = false;
381 std::string mReason; // user-facing modal copy
382
383 // Snapshot — captured at restart entry, restored after OpenProject.
384 std::string mProjectPath;
385 std::vector<std::string> mOpenSceneNames; // names of edit scenes to reopen
386 std::string mActiveSceneName; // active edit scene at snapshot
387
388 // Per-scene dirty queue. Walked one-at-a-time during AwaitingDirty.
389 std::vector<std::string> mDirtyScenes;
390 int32_t mDirtyCursor = 0;
391 };
392
393 bool IsProjectRestartActive() const { return mRestart.mPhase != ProjectRestartPhase::None; }
394 const ProjectRestart& GetProjectRestart() const { return mRestart; }
395
400 void ReloadNativeAddonsWithProjectRestart(const std::vector<std::string>& addonIds,
401 bool forceRebuild,
402 const char* reason);
403
404 // Modal callbacks. Called from the EditorImgui modal renderers when the
405 // user clicks the corresponding button. Public because the modal lives
406 // outside this class.
407 void ProjectRestartConfirm(); // [Continue] on confirm modal
408 void ProjectRestartCancel(); // [Cancel] on confirm modal — abort whole flow
409 void ProjectRestartDirtySave(); // [Save] for the current dirty scene
410 void ProjectRestartDirtyDiscard(); // [Discard] for the current dirty scene
411 void ProjectRestartDirtyCancel(); // [Cancel] in dirty prompt — abort whole flow
412
421 void TickAllPlugins(float deltaTime);
422
431 void TickEditorAllPlugins(float deltaTime);
432
436 void CallOnEditorPreInit();
437
441 void CallOnEditorReady();
442
443 // ===== State Queries =====
444
451 const NativeAddonState* GetState(const std::string& addonId) const;
452
456 bool IsLoaded(const std::string& addonId) const;
457
464 std::string GetAddonSourcePath(const std::string& addonId) const;
465
478 std::string FindAddonRootForBuildTarget(const std::string& buildTargetId) const;
479
483 std::vector<NativeAddonState> GetEngineAddons() const;
484
488 PolyphaseEngineAPI* GetEngineAPI() { return &mEngineAPI; }
489
502 const char* FindAddonIdForFactory(const void* factoryPtr) const;
503
504 // ===== Creation and Packaging =====
505
520 bool CreateNativeAddon(const NativeAddonCreateInfo& info, std::string& outError, std::string* outPath = nullptr);
521
534 bool CreateNativeAddonAtPath(const NativeAddonCreateInfo& info, const std::string& targetDir,
535 std::string& outError, std::string* outPath = nullptr);
536
546 bool PackageNativeAddon(const NativeAddonPackageOptions& options, std::string& outError);
547
556 bool GenerateIDEConfig(const std::string& addonPath);
557
561 std::vector<std::string> GetLocalPackageIds() const;
562
571 static bool GenerateAddonIncludesManifest();
572
580 static bool LoadAddonIncludesManifest(std::vector<std::string>& outIncludePaths,
581 std::vector<std::string>& outDefines);
582
583private:
584 static NativeAddonManager* sInstance;
585 NativeAddonManager();
586 ~NativeAddonManager();
587
588 // Discovery helpers
589 void ScanLocalPackages();
590 void ScanInstalledAddons();
591 bool ParsePackageJson(const std::string& path, NativeModuleMetadata& outMetadata, ContentMetadata* outContent = nullptr);
592
596 std::vector<std::string> GetLoadOrder() const;
597
598 // Cached topo order produced by the most recent ResolveAll() during discovery.
599 std::vector<std::string> mCachedLoadOrder;
600
601 // Build helpers
602 std::string GetIntermediateDir(const std::string& addonId);
603 std::string GetOutputPath(const std::string& addonId, const std::string& fingerprint);
604 bool GenerateBuildScript(const std::string& addonId, const std::string& outputDir,
605 const std::string& outputPath, std::string& outScriptPath);
606 std::vector<std::string> GatherSourceFiles(const std::string& sourceDir);
607
614 std::vector<std::string> TryClearAddonIntermediates(const std::string& addonId);
615
616 // Engine API setup
617 void InitializeEngineAPI();
618
619 // Creation helpers
620 std::string GenerateIdFromName(const std::string& name);
621 bool WriteTemplateSourceFile(const std::string& path, const std::string& addonName,
622 const std::string& binaryName);
623 bool WritePackageJson(const std::string& path, const NativeAddonCreateInfo& info);
624 bool WriteVSCodeConfig(const std::string& addonPath);
625 bool WriteCMakeLists(const std::string& addonPath, const std::string& binaryName);
626 bool WriteVSProject(const std::string& addonPath, const std::string& addonName,
627 const std::string& binaryName);
628
629 std::unordered_map<std::string, NativeAddonState> mStates;
630 PolyphaseEngineAPI mEngineAPI;
631
632 // ----- Shadow-copy load cache -----
633 //
634 // The editor LoadLibrary's an addon DLL from a per-launch cache dir rather
635 // than from Intermediate/Plugins/<addon>/<fp>/ directly. That keeps the
636 // build-output tree free of OS file locks so the user can wipe / rebuild
637 // intermediates while the editor is running. See NativeAddonManager.cpp
638 // GetShadowCopyPath / SweepStaleShadowCopies for the layout.
639 std::string mShadowSessionId; // PID-derived, set in ctor
640 std::vector<std::string> mPendingShadowDeletes; // shadow dirs to retry on Tick
641
642 std::string GetShadowCopyDir(const std::string& addonId,
643 const std::string& fingerprint);
644 bool StageShadowCopy(const std::string& sourceModulePath,
645 const std::string& shadowDir,
646 std::string& outShadowModulePath,
647 std::string& outError);
648 void TryDeleteShadowDir(const std::string& dir);
649 void SweepStaleShadowCopies();
650
651 // ----- Async build queue -----
652 //
653 // One worker thread shells out to build.bat / build.sh per addon. The
654 // main thread polls completion in TickAsyncBuilds(), runs the post-
655 // build steps (write meta, MOD_Load, register types), and starts the
656 // next queued item. This keeps the editor interactive while addons
657 // compile, especially during multi-addon Force Rebuild.
658 struct AsyncAddonBuild
659 {
660 std::string addonId;
661 std::string scriptPath;
662 std::string outputPath;
663 std::string fingerprint;
664
665 std::thread thread;
666 std::atomic<bool> complete{false};
667 std::atomic<int> exitCode{0};
668
669 mutable std::mutex outputMutex;
670 std::string output; // guarded by outputMutex
671 };
672
673 std::unique_ptr<AsyncAddonBuild> mActiveBuild;
674 std::vector<std::string> mBuildQueue;
675 int mBuildQueueTotal = 0;
676 int mBuildQueueIndex = 0; // 1-based, advanced when a build starts
677
678 // Set when a pre-build sweep finds locked files in the intermediate dir.
679 BuildBlocked mBlocked;
680
681 // Carries the retry counter from one RetryBlockedBuild into the next
682 // BuildBlocked the (synchronous or async) build path raises. Reset to 0
683 // after the value lands in mBlocked.mRetryCount. Without this, the
684 // counter would reset every time we transiently clear mBlocked at the
685 // top of RetryBlockedBuild — the modal would never see "this is the
686 // user's third try" and never escalate to Tier 2.
687 int mPendingRetryCount = 0;
688
689 // Project-restart state machine. See ProjectRestartPhase for the flow.
690 ProjectRestart mRestart;
691
692 // One-shot per-addon override: addon IDs in this set get their next
693 // LoadNativeAddon() invocation treated as resolveMode=source even when
694 // package.json says "binary". Set when the user clicks Reload Native
695 // Addons on a binary-mode addon — Reload means "recompile my local
696 // source", not "redownload the published binary". Consumed (erased)
697 // on first read so the override doesn't persist beyond a single load.
698 std::unordered_set<std::string> mForceSourceForNextLoad;
699
700 // Internal helpers
701 void StartNextQueuedBuild();
702 void FinalizeAsyncBuild(AsyncAddonBuild& job, bool success);
703 bool LoadNativeAddonAfterBuild(const std::string& addonId, std::string& outError);
704
705 // Project-restart helpers
706 void ProjectRestartBeginClose(); // dirty queue exhausted → close project + enqueue rebuilds
707 void ProjectRestartOnBuildsDone(); // called from TickAsyncBuilds when in Building phase
708 void ProjectRestartReset(); // clear state back to Phase::None
709};
710
711#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:67