diff --git a/.gitignore b/.gitignore index 426751e55..eea5e4e1a 100644 --- a/.gitignore +++ b/.gitignore @@ -179,6 +179,7 @@ UnrealFolder/ProjectMobius/Plugins/Hdf5DataPlugin/Source/ThirdParty/hdf5-2.0.0/i UnrealFolder/ProjectMobius/Plugins/Hdf5DataPlugin/Source/ThirdParty/hdf5-2.0.0/install/lib/pkgconfig/ UnrealFolder/ProjectMobius/Plugins/Hdf5DataPlugin/Source/ThirdParty/hdf5-2.0.0/install/lib/libhdf5.settings UnrealFolder/ProjectMobius/Plugins/Hdf5DataPlugin/Source/ThirdParty/hdf5-2.0.0/install/include/H5pubconf.h +UnrealFolder/ProjectMobius/Plugins/Hdf5DataPlugin/Source/ThirdParty/hdf5-2.0.0/install/share/ # HDF5 third-party (scoped to vendored path) UnrealFolder/ProjectMobius/Plugins/Hdf5DataPlugin/Source/ThirdParty/hdf5-2.0.0/**/java/.classes @@ -190,6 +191,7 @@ UnrealFolder/ProjectMobius/Plugins/Hdf5DataPlugin/Source/ThirdParty/hdf5-2.0.0/. UnrealFolder/ProjectMobius/Plugins/Hdf5DataPlugin/Source/ThirdParty/hdf5-2.0.0/CLAUDE.md UnrealFolder/ProjectMobius/Plugins/Hdf5DataPlugin/Source/ThirdParty/hdf5-2.0.0/CMakeUserPresets.json UnrealFolder/ProjectMobius/Plugins/Hdf5DataPlugin/Source/ThirdParty/hdf5-2.0.0/HDF5Examples/CMakeUserPresets.json +UnrealFolder/ProjectMobius/UnitTestSampleData/**/MobiusCaptures/ # ===================================================== diff --git a/UnrealFolder/ProjectMobius/Config/DefaultEngine.ini b/UnrealFolder/ProjectMobius/Config/DefaultEngine.ini index 8c9ad6980..f84a2c314 100644 --- a/UnrealFolder/ProjectMobius/Config/DefaultEngine.ini +++ b/UnrealFolder/ProjectMobius/Config/DefaultEngine.ini @@ -93,7 +93,6 @@ r.TemporalAA.Upsampling=False r.DefaultFeature.Bloom=False r.DefaultBackBufferPixelFormat=4 r.AllowStaticLighting=False -r.MegaLights.EnableForProject=True r.RayTracing.Shadows=True r.VertexFoggingForOpaque=False r.Velocity.EnableVertexDeformation=1 @@ -374,3 +373,5 @@ AutoRepair=False bAutoRepair=False EnabledByDefault=False +[/Script/MacTargetPlatform.XcodeProjectSettings] +bMacSignToRunLocally=True diff --git a/UnrealFolder/ProjectMobius/Config/Mac/MacEngine.ini b/UnrealFolder/ProjectMobius/Config/Mac/MacEngine.ini new file mode 100644 index 000000000..709526ba2 --- /dev/null +++ b/UnrealFolder/ProjectMobius/Config/Mac/MacEngine.ini @@ -0,0 +1,2 @@ +[/Script/Engine.RendererSettings] +r.MegaLights.EnableForProject=False diff --git a/UnrealFolder/ProjectMobius/Config/Windows/WindowsEngine.ini b/UnrealFolder/ProjectMobius/Config/Windows/WindowsEngine.ini new file mode 100644 index 000000000..08ffc6221 --- /dev/null +++ b/UnrealFolder/ProjectMobius/Config/Windows/WindowsEngine.ini @@ -0,0 +1,2 @@ +[/Script/Engine.RendererSettings] +r.MegaLights.EnableForProject=True diff --git a/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Util/FrameGrabberHelper.cpp b/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Util/FrameGrabberHelper.cpp index d78edae7e..02882abbd 100644 --- a/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Util/FrameGrabberHelper.cpp +++ b/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Util/FrameGrabberHelper.cpp @@ -7,6 +7,7 @@ #include "HAL/PlatformFileManager.h" #include "ImageUtils.h" #include "Misc/FileHelper.h" +#include "Misc/Paths.h" #include "RenderingThread.h" // For FlushRenderingCommands (Mac GPU sync) #include "Slate/SceneViewport.h" #include "Subsystems/MobiusCustomLoggerSubsystem.h" @@ -76,6 +77,45 @@ namespace }); } } + +#if PLATFORM_MAC + bool IsLikelyMacPrivacyProtectedPath(const FString& Path) + { + FString NormalizedPath = Path; + FPaths::NormalizeFilename(NormalizedPath); + + FString UserDir = FPlatformProcess::UserDir(); + FPaths::NormalizeDirectoryName(UserDir); + + const TArray ProtectedRoots = + { + FPaths::Combine(UserDir, TEXT("Desktop")), + FPaths::Combine(UserDir, TEXT("Documents")), + FPaths::Combine(UserDir, TEXT("Downloads")) + }; + + for (FString ProtectedRoot : ProtectedRoots) + { + FPaths::NormalizeDirectoryName(ProtectedRoot); + if (NormalizedPath.StartsWith(ProtectedRoot)) + { + return true; + } + } + + return false; + } + + FString BuildMacWriteFailureDetail(const FString& Path) + { + if (IsLikelyMacPrivacyProtectedPath(Path)) + { + return TEXT(" macOS Files & Folders privacy may be blocking access to this Desktop/Documents/Downloads location."); + } + + return TEXT(" The path is valid, so the failure is likely a macOS permission or Unreal screenshot write-path issue."); + } +#endif } void UFrameGrabberHelper::Configure(bool bInUseFullResolution, FIntPoint InDownscaleSize) @@ -103,6 +143,21 @@ void UFrameGrabberHelper::Configure(bool bInUseFullResolution, FIntPoint InDowns #endif } +void UFrameGrabberHelper::BeginDestroy() +{ +#if PLATFORM_MAC + ResetMacCaptureState(); + + if (bMacScreenshotDelegateBound) + { + UGameViewportClient::OnScreenshotCaptured().RemoveAll(this); + bMacScreenshotDelegateBound = false; + } +#endif + + Super::BeginDestroy(); +} + #if !PLATFORM_MAC bool UFrameGrabberHelper::TryInitialize() { @@ -258,35 +313,68 @@ void UFrameGrabberHelper::TriggerCapture(const FString& InFileName) MobiusLog(FString::Printf(TEXT("[FrameGrabber][Mac] Created directory: %s"), *DestPath)); } - // Full path for the screenshot (without .png - engine may add it) - FString FullPath = DestPath / PendingFileName; + PendingOutputPath = DestPath / (PendingFileName + TEXT(".png")); + FPaths::NormalizeFilename(PendingOutputPath); + + FString PreflightFailureReason; + if (!PreflightMacScreenshotWrite(DestPath, PreflightFailureReason)) + { + MobiusLog(FString::Printf(TEXT("[FrameGrabber][Mac] Error: Write preflight failed for %s. Reason: %s"), + *DestPath, + *PreflightFailureReason)); + MobiusReportError( + FText::FromString(TEXT("Save Permission Error")), + FText::FromString(PreflightFailureReason), + FText::FromString(TEXT("FrameGrabberHelper::TriggerCapture")), + EMobiusErrorSeverity::Error, + true); + PendingOutputPath.Empty(); + return; + } + + if (!bMacScreenshotDelegateBound) + { + UGameViewportClient::OnScreenshotCaptured().AddUObject(this, &UFrameGrabberHelper::HandleMacScreenshotCaptured); + bMacScreenshotDelegateBound = true; + } - MobiusLog(FString::Printf(TEXT("[FrameGrabber][Mac] Requesting screenshot via FScreenshotRequest: %s"), *FullPath)); + MobiusLog(FString::Printf(TEXT("[FrameGrabber][Mac] Requesting screenshot via FScreenshotRequest: %s"), *PendingOutputPath)); - // Request screenshot with filename, no HDR, show UI notification, no unique suffix - // Signature: RequestScreenshot(const FString& Filename, bool bShowUI, bool bAddFilenameSuffix, bool bInHDR) - FScreenshotRequest::RequestScreenshot(FullPath, true, false, false); + // Request screenshot with filename, no HDR, show UI notification, no unique suffix. + // We still request Unreal's screenshot flow, but the pixel buffer is saved by our own delegate so the + // final write reports explicit permission/path failures instead of silently disappearing. + FScreenshotRequest::RequestScreenshot(PendingOutputPath, true, false, false); bIsCapturing = true; - // Reset capturing flag after a delay since the engine saves asynchronously - FTimerHandle TimerHandle; if (GEngine && GEngine->GameViewport && GEngine->GameViewport->GetWorld()) { GEngine->GameViewport->GetWorld()->GetTimerManager().SetTimer( - TimerHandle, + MacCaptureTimeoutHandle, [this]() { - bIsCapturing = false; - MobiusLog(TEXT("[FrameGrabber][Mac] Capture flag reset after timeout.")); + if (!bIsCapturing) + { + return; + } + + const FString TimedOutOutputPath = PendingOutputPath; + ResetMacCaptureState(); + MobiusLog(FString::Printf(TEXT("[FrameGrabber][Mac] Error: Screenshot capture timed out before a save callback for %s"), *TimedOutOutputPath)); + MobiusReportError( + FText::FromString(TEXT("Capture Timed Out")), + FText::FromString(TEXT("The screenshot was requested, but macOS never delivered image data back to the game. This points to an Unreal capture-path problem rather than simple folder creation.")), + FText::FromString(TEXT("FrameGrabberHelper::TriggerCapture")), + EMobiusErrorSeverity::Error, + true); }, - 0.5f, + 3.0f, false ); } else { MobiusLog(TEXT("[FrameGrabber][Mac] Warning: Could not set timer for capture reset - no valid world context.")); - bIsCapturing = false; + ResetMacCaptureState(); } #else // Windows/other: Use FFrameGrabber @@ -340,6 +428,110 @@ void UFrameGrabberHelper::TriggerCapture(const FString& InFileName) #endif } +#if PLATFORM_MAC +bool UFrameGrabberHelper::PreflightMacScreenshotWrite(const FString& DestPath, FString& OutFailureReason) const +{ + const FString ProbePath = DestPath / FString::Printf(TEXT(".mobius_write_probe_%llu.tmp"), static_cast(FPlatformTime::Cycles64())); + const bool bProbeWritten = FFileHelper::SaveStringToFile( + TEXT("probe"), + *ProbePath, + FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM); + + if (!bProbeWritten) + { + OutFailureReason = FString::Printf( + TEXT("Project Mobius could create or resolve the folder but could not create a test file in %s.%s"), + *DestPath, + *BuildMacWriteFailureDetail(DestPath)); + return false; + } + + IFileManager::Get().Delete(*ProbePath, false, true, true); + return true; +} + +void UFrameGrabberHelper::HandleMacScreenshotCaptured(int32 InSizeX, int32 InSizeY, const TArray& InImageData) +{ + if (!bIsCapturing) + { + return; + } + + if (PendingOutputPath.IsEmpty()) + { + MobiusLog(TEXT("[FrameGrabber][Mac] Warning: Received screenshot pixels with no pending output path.")); + ResetMacCaptureState(); + return; + } + + if (InSizeX <= 0 || InSizeY <= 0 || InImageData.Num() == 0) + { + const FString FailedOutputPath = PendingOutputPath; + ResetMacCaptureState(); + MobiusLog(FString::Printf(TEXT("[FrameGrabber][Mac] Error: Invalid screenshot pixel data for %s"), *FailedOutputPath)); + MobiusReportError( + FText::FromString(TEXT("Capture Failed")), + FText::FromString(TEXT("The screenshot completed but returned no pixel data.")), + FText::FromString(TEXT("FrameGrabberHelper::HandleMacScreenshotCaptured")), + EMobiusErrorSeverity::Error, + true); + return; + } + + const FString OutputPath = PendingOutputPath; + TArray ImageData = InImageData; + for (FColor& Pixel : ImageData) + { + Pixel.A = 255; + } + + Async(EAsyncExecution::ThreadPool, [WeakThis = TWeakObjectPtr(this), OutputPath, InSizeX, InSizeY, ImageData = MoveTemp(ImageData)]() mutable + { + TArray64 PNGData; + FImageUtils::PNGCompressImageArray(InSizeX, InSizeY, ImageData, PNGData); + + const bool bSaved = PNGData.Num() > 0 && FFileHelper::SaveArrayToFile(PNGData, *OutputPath); + + AsyncTask(ENamedThreads::GameThread, [WeakThis, OutputPath, InSizeX, InSizeY, bSaved]() + { + if (!WeakThis.IsValid()) + { + return; + } + + WeakThis->ResetMacCaptureState(); + + if (!bSaved) + { + MobiusLog(FString::Printf(TEXT("[FrameGrabber][Mac] Error: Failed to save screenshot after capture: %s"), *OutputPath)); + MobiusReportError( + FText::FromString(TEXT("Save Failed")), + FText::FromString(FString::Printf(TEXT("Captured screenshot pixels successfully but could not write %s.%s"), *OutputPath, *BuildMacWriteFailureDetail(OutputPath))), + FText::FromString(TEXT("FrameGrabberHelper::HandleMacScreenshotCaptured")), + EMobiusErrorSeverity::Error, + true); + return; + } + + MobiusLog(FString::Printf(TEXT("[FrameGrabber][Mac] Screenshot saved successfully (%dx%d): %s"), + InSizeX, InSizeY, *OutputPath)); + }); + }); +} + +void UFrameGrabberHelper::ResetMacCaptureState() +{ + if (GEngine && GEngine->GameViewport && GEngine->GameViewport->GetWorld() && MacCaptureTimeoutHandle.IsValid()) + { + GEngine->GameViewport->GetWorld()->GetTimerManager().ClearTimer(MacCaptureTimeoutHandle); + } + + MacCaptureTimeoutHandle.Invalidate(); + PendingOutputPath.Empty(); + bIsCapturing = false; +} +#endif + void UFrameGrabberHelper::Tick(float DeltaTime) { #if PLATFORM_MAC diff --git a/UnrealFolder/ProjectMobius/Source/MobiusCore/Public/Util/FrameGrabberHelper.h b/UnrealFolder/ProjectMobius/Source/MobiusCore/Public/Util/FrameGrabberHelper.h index d80efe686..4c7ca0fbd 100644 --- a/UnrealFolder/ProjectMobius/Source/MobiusCore/Public/Util/FrameGrabberHelper.h +++ b/UnrealFolder/ProjectMobius/Source/MobiusCore/Public/Util/FrameGrabberHelper.h @@ -34,6 +34,8 @@ class MOBIUSCORE_API UFrameGrabberHelper : public UObject /** Tick to be called each frame to poll frames internally */ void Tick(float DeltaTime); + virtual void BeginDestroy() override; + bool IsCapturing() const { return bIsCapturing; } #if PLATFORM_MAC bool IsInitialized() const { return true; } // Mac uses FScreenshotRequest, always "initialized" @@ -49,6 +51,21 @@ class MOBIUSCORE_API UFrameGrabberHelper : public UObject TUniquePtr FrameGrabber; #endif +#if PLATFORM_MAC + /** Try creating and removing a small probe file so macOS write denials are surfaced early. */ + bool PreflightMacScreenshotWrite(const FString& DestPath, FString& OutFailureReason) const; + + /** Handle the raw screenshot pixels so macOS writes go through our own code path. */ + void HandleMacScreenshotCaptured(int32 InSizeX, int32 InSizeY, const TArray& InImageData); + + /** Reset macOS screenshot state and clear any timeout. */ + void ResetMacCaptureState(); + + bool bMacScreenshotDelegateBound = false; + FString PendingOutputPath; + FTimerHandle MacCaptureTimeoutHandle; +#endif + FIntPoint TargetSize; FIntPoint ViewportSize; FIntPoint ConfiguredDownscaleSize = FIntPoint(800, 600); diff --git a/docs/BUILD-AND-RUN.md b/docs/BUILD-AND-RUN.md index ac38541b2..053d7d04f 100644 --- a/docs/BUILD-AND-RUN.md +++ b/docs/BUILD-AND-RUN.md @@ -8,7 +8,7 @@ This walkthrough covers the full developer setup pipeline for Project Mobius: - Clone the repository - Configure and build with CMake -- Compile the Unreal target +- Open the Unreal project and let Unreal rebuild missing modules when needed - Package a distributable build* - Add Twinmotion compatibility - Understand Datasmith modes in Unreal Engine (`Runtime Datasmith` vs `Twinmotion Datasmith`) @@ -43,12 +43,21 @@ Common: macOS (Apple Silicon) - macOS 11 or newer on Apple Silicon -- Xcode Command Line Tools +- Full Xcode selected via `xcode-select` +- Unreal Engine 5.5 in the local engine source supports Xcode `15.2.0` + through `16.9.0` +- These notes were last tested with `Xcode 16.4` - Ninja macOS support exists in parts of the codebase, but Windows is still the primary development and validation target. +Known macOS limitation: heatmap Gaussian blur is currently unavailable. The +current Visualization module relies on Epic's built-in OpenCV plugin, and this +project does not yet ship the Mac OpenCV libraries needed for that path. +Supporting Gaussian blur on macOS will require a custom plugin or an equivalent +code-side replacement. + ## Clone the Repository @@ -78,8 +87,12 @@ cmake --build _superbuild --config Release --parallel Build the Unreal target after the superbuild: 1. Open `UnrealFolder/ProjectMobius/ProjectMobius.uproject` in Unreal Engine 5.5. -2. Generate Visual Studio project files from the `.uproject` if needed. -3. Build `ProjectMobiusEditor` in `Development Editor`. +2. If Unreal prompts to rebuild missing modules, let it compile the editor + target. +3. If you want IDE integration, open the `.uproject` directly in an IDE that + supports it, or generate Visual Studio project files if your setup requires + them. Build `ProjectMobiusEditor` manually only if the automatic rebuild + fails. @@ -94,8 +107,11 @@ cmake --build _superbuild --parallel Build the Unreal target after the superbuild: 1. Open `UnrealFolder/ProjectMobius/ProjectMobius.uproject` in Unreal Engine 5.5. -2. Generate IDE project files from the `.uproject` if you need them for your platform. -3. Build `ProjectMobiusEditor` in `Development Editor`. +2. If Unreal prompts to rebuild missing modules, let it compile the editor + target. +3. If you want IDE integration, generate the Xcode project or workspace from + the `.uproject` if your setup requires it. Build `ProjectMobiusEditor` + manually only if the automatic rebuild fails. @@ -170,7 +186,9 @@ UnrealEditor-Cmd.exe ProjectMobius.uproject -run=GenerateDatasmithMaterials -una Or use the provided scripts: `Scripts/GenerateDatasmithMaterials.bat` (Windows) / `Scripts/GenerateDatasmithMaterials.sh` (macOS/Linux). -## Package Example +## Package + +### Example command ```bash RunUAT.bat BuildCookRun ^ @@ -180,6 +198,90 @@ RunUAT.bat BuildCookRun ^ -archivedirectory=./Binaries/Release ``` +### macOS packaged build configuration + +Project Mobius currently packages macOS development builds with the App +Sandbox disabled. This is intentional for the current file workflow. +The packaged app opens user-selected simulation data files and writes +screenshots and related output into a `MobiusCaptures` folder beside the +selected data file. That flow is not currently implemented against the stricter +macOS sandbox access model, so the App Sandbox must remain disabled for these +builds. + +Local macOS packaging uses the following entitlement templates when they are +present: + +- `UnrealFolder/ProjectMobius/Build/Mac/Resources/Sandbox.NoNet.entitlements` +- `UnrealFolder/ProjectMobius/Build/Mac/Resources/Sandbox.Server.entitlements` + +These are local packaging resources, not authored project source. A fresh +checkout may not contain them yet. + +If these files are missing on your machine, run one local macOS packaging pass +first so Unreal creates the local Mac packaging resources. Then review the +local entitlement files before packaging again. + +Because this is a public repository, keep files under +`UnrealFolder/ProjectMobius/Build/Mac/Resources/` local and do not commit them. +Do not commit generated files under `Saved/StagedBuilds/...` either. + +In both files, keep the App Sandbox disabled: + +```xml +com.apple.security.app-sandbox + +``` + +The network-enabled entitlement file also keeps client and server access +enabled: + +```xml +com.apple.security.network.client + +com.apple.security.network.server + +``` + +For local macOS packaging, keep the following setting in +`UnrealFolder/ProjectMobius/Config/DefaultEngine.ini`: + +```ini +[/Script/MacTargetPlatform.XcodeProjectSettings] +bMacSignToRunLocally=True +``` + +After packaging, verify the generated entitlements in: + +`UnrealFolder/ProjectMobius/Saved/StagedBuilds/ProjectMobius (Mac).build/Mac/ProjectMobius.build/DerivedSources/Entitlements.plist` + +Do not edit the generated entitlements file directly. It is recreated during +packaging. The source of truth is the entitlement templates under +`UnrealFolder/ProjectMobius/Build/Mac/Resources/`. + +### macOS packaged build runtime permissions + +Disabling the App Sandbox does not bypass macOS privacy prompts. If +the packaged app opens or saves data in protected folders such as `Documents`, +`Desktop`, or `Downloads`, macOS may still require the user to approve access +before Project Mobius can save screenshots beside the selected data file. + +To review or change that permission on macOS: + +1. Open `Apple menu > System Settings`. +2. Go to `Privacy & Security`. +3. Open `Files & Folders`. +4. Find `Project Mobius`. +5. Enable access for the folder in use, such as `Documents`, `Desktop`, or + `Downloads`. + +If `Project Mobius` is not listed yet, launch the packaged build and try +opening or saving a file in the target folder once. macOS should then prompt +for access. + +Repackaged local builds may need this approval again after a rebuild, because +macOS tracks Files & Folders access against the packaged app's signing +identity. + ## Related References - [UE Automation Guide](../UnrealFolder/ProjectMobius/BuildDocs/UE-Automation-Guide.md)