From 372450e2b5fa935be48c96bff12d6fbfa1f65b35 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Thu, 3 Sep 2026 20:46:00 -0500 Subject: [PATCH 01/25] Move Unreal Android library mode into UPL The library mode additions to GameActivity.java used to be spliced in by game-cli after packaging, keyed off a literal line of Epic's source. When that line moved between engine versions the splice matched nothing, logged a warning, and reported success, so you got a clean export, a clean build and a black rectangle. UPL runs inside UnrealBuildTool while it generates the intermediate Android project, so the additions are declared here instead and are already present before game-cli looks at the tree. A schema change now breaks packaging in the Editor with a node name you can search for. Also adds ProGuard keep rules for the entry points. The Kotlin controller reaches them by reflection, so R8 sees no callers and would strip them out of a minified release build, leaving a debug build that works and a release build that does not. The launcher intent removal deliberately stays in game-cli. UPL runs for every Android build of the project, including plain standalone packaging, so stripping it here would break normal APKs. --- .../FlutterPlugin_Android_UPL.xml | 316 +++++++++++++++++- 1 file changed, 314 insertions(+), 2 deletions(-) diff --git a/engines/unreal/plugin/Source/FlutterPlugin/FlutterPlugin_Android_UPL.xml b/engines/unreal/plugin/Source/FlutterPlugin/FlutterPlugin_Android_UPL.xml index 2253364..6d1664d 100644 --- a/engines/unreal/plugin/Source/FlutterPlugin/FlutterPlugin_Android_UPL.xml +++ b/engines/unreal/plugin/Source/FlutterPlugin/FlutterPlugin_Android_UPL.xml @@ -1,5 +1,22 @@ - + @@ -11,12 +28,307 @@ + + + +-keep class com.epicgames.unreal.GameActivity { + public static <methods>; +} +-keepclasseswithmembernames class com.epicgames.unreal.GameActivity { + native <methods>; +} + + + + + + + + +import android.app.Activity; +import android.content.Context; +import android.os.Bundle; +import android.view.SurfaceHolder; + - + + + + From 13e80a3819419c5534a9b2fd290688b6f3596e35 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Thu, 3 Sep 2026 20:46:19 -0500 Subject: [PATCH 02/25] Implement the Unreal bridge for iOS and macOS Both platforms had a stub. FlutterBridge_IOS.cpp and FlutterBridge_Mac.cpp logged their arguments and returned, with the real work marked TODO, so nothing flowed between Unreal and Flutter on either. The boundary is now a flat C ABI, declared in Public/UnrealBridge.h and free of Unreal types, so your app never needs CoreMinimal.h, UBT include paths or engine symbols of its own. Messages go out through callbacks you register, and come in through UnrealBridge_SendToUnreal and friends. Every entry point is safe from any thread and hops to the game thread internally; callbacks fire on the game thread, so the Objective-C layer copies and dispatches to main before touching your controller. iOS and Mac differ only in the names AFlutterBridge dispatches to, so the implementation lives once in Private/FlutterBridge_Apple.cpp with four shims at the bottom. The two old platform files are gone. On the Flutter side the pods resolve the ABI with dlsym rather than linking against it. UnrealFramework is produced by an export step that may not have run yet, so linking would break those builds, and weak_import does not help because it only makes a symbol optional at load time while the static linker still demands a definition. Looking the symbols up at runtime keeps the pod self-contained and turns a missing framework into one clear log line. macOS needed more than a rewrite. UnrealBridge.mm did not compile at all: it imported a Swift file into Objective-C++, and since the podspec globs Classes/**/*, adding Unreal on macOS broke the pod build outright. There were also two classes called UnrealBridge, one here and one declared in Swift with every method returning false under a comment promising the real version lived in the .mm. Two Objective-C classes under one name means the runtime picks one without telling you, and the placeholder is the one the controller called. It is deleted, and the class is now declared in UnrealBridge.h with NS_SWIFT_NAME so the existing Swift call sites are unchanged. Tests run in the Simulator on iOS and natively on macOS, against a mock framework exporting the same ABI, covering both directions plus the framework-absent path: engines/unreal/dart/ios/Tests/run_bridge_tests.sh engines/unreal/dart/macos/Tests/run_bridge_tests.sh The engine half has not been compiled by UnrealBuildTool yet. --- .../unreal/dart/ios/Classes/UnrealBridge.mm | 454 ++++++++-------- .../dart/ios/Tests/MockUnrealFramework.c | 49 ++ .../dart/ios/Tests/UnrealBridgeAbsentTests.mm | 41 ++ .../dart/ios/Tests/UnrealBridgeTests.mm | 99 ++++ .../unreal/dart/ios/Tests/run_bridge_tests.sh | 72 +++ .../unreal/dart/macos/Classes/UnrealBridge.h | 58 ++ .../unreal/dart/macos/Classes/UnrealBridge.mm | 505 ++++++++---------- .../Classes/UnrealEngineController.swift | 83 +-- .../dart/macos/Tests/MockUnrealFramework.c | 49 ++ .../dart/macos/Tests/SwiftApiCheck.swift | 19 + .../macos/Tests/UnrealBridgeAbsentTests.mm | 34 ++ .../dart/macos/Tests/UnrealBridgeTests.mm | 91 ++++ .../dart/macos/Tests/run_bridge_tests.sh | 54 ++ .../Private/FlutterBridge_Apple.cpp | 431 +++++++++++++++ .../Private/IOS/FlutterBridge_IOS.cpp | 77 --- .../Private/Mac/FlutterBridge_Mac.cpp | 77 --- .../FlutterPlugin/Public/UnrealBridge.h | 120 +++++ 17 files changed, 1589 insertions(+), 724 deletions(-) create mode 100644 engines/unreal/dart/ios/Tests/MockUnrealFramework.c create mode 100644 engines/unreal/dart/ios/Tests/UnrealBridgeAbsentTests.mm create mode 100644 engines/unreal/dart/ios/Tests/UnrealBridgeTests.mm create mode 100755 engines/unreal/dart/ios/Tests/run_bridge_tests.sh create mode 100644 engines/unreal/dart/macos/Classes/UnrealBridge.h create mode 100644 engines/unreal/dart/macos/Tests/MockUnrealFramework.c create mode 100644 engines/unreal/dart/macos/Tests/SwiftApiCheck.swift create mode 100644 engines/unreal/dart/macos/Tests/UnrealBridgeAbsentTests.mm create mode 100644 engines/unreal/dart/macos/Tests/UnrealBridgeTests.mm create mode 100755 engines/unreal/dart/macos/Tests/run_bridge_tests.sh create mode 100644 engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp delete mode 100644 engines/unreal/plugin/Source/FlutterPlugin/Private/IOS/FlutterBridge_IOS.cpp delete mode 100644 engines/unreal/plugin/Source/FlutterPlugin/Private/Mac/FlutterBridge_Mac.cpp create mode 100644 engines/unreal/plugin/Source/FlutterPlugin/Public/UnrealBridge.h diff --git a/engines/unreal/dart/ios/Classes/UnrealBridge.mm b/engines/unreal/dart/ios/Classes/UnrealBridge.mm index 53ff2d8..e6d1e43 100644 --- a/engines/unreal/dart/ios/Classes/UnrealBridge.mm +++ b/engines/unreal/dart/ios/Classes/UnrealBridge.mm @@ -1,258 +1,181 @@ // Copyright Epic Games, Inc. All Rights Reserved. -// Check if UnrealFramework is available -#if __has_include("FlutterBridge.h") - #import #import -#include "FlutterBridge.h" - -// Forward declare the Swift controller class -@class UnrealEngineController; - -// Reference to FlutterBridge instance (Unreal Engine side) -static AFlutterBridge* GFlutterBridgeInstance = nullptr; - -// Reference to UnrealEngineController (Swift side) -static id GUnrealEngineController = nil; // ============================================================ -// MARK: - Helper Functions +// MARK: - UnrealFramework C ABI // ============================================================ - -NSString* FStringToNSString(const FString& String) -{ - return [NSString stringWithUTF8String:TCHAR_TO_UTF8(*String)]; -} - -FString NSStringToFString(NSString* String) -{ - if (!String) return FString(); - return FString(UTF8_TO_TCHAR([String UTF8String])); -} - -TMap NSDictionaryToTMap(NSDictionary* Dictionary) -{ - TMap Result; - if (!Dictionary) return Result; - - for (NSString* key in Dictionary) - { - id value = [Dictionary objectForKey:key]; - NSString* valueStr = [value isKindOfClass:[NSString class]] ? value : [NSString stringWithFormat:@"%@", value]; - Result.Add(NSStringToFString(key), NSStringToFString(valueStr)); - } - return Result; -} - -NSDictionary* TMapToNSDictionary(const TMap& Map) -{ - NSMutableDictionary* Dictionary = [NSMutableDictionary dictionary]; - for (const auto& Entry : Map) - { - [Dictionary setObject:@(Entry.Value) forKey:FStringToNSString(Entry.Key)]; - } - return Dictionary; +// +// Resolved with dlsym rather than linked. The canonical declarations live in +// the plugin's Public/UnrealBridge.h and are copied into the framework's +// Headers/ at export time; keep the signatures below in step with them. +// +// Why dlsym and not a link-time dependency: UnrealFramework is produced by +// "game export unreal -p ios", so it may legitimately be absent when this pod +// is built. Linking against it would break those builds, and weak_import does +// not help, because it only makes a symbol optional at load time while the +// static linker still demands a definition. Looking the symbols up at runtime +// keeps the pod self-contained and turns "framework missing" into a clear log +// line instead of a build failure. +// +// This replaces an older __has_include split that decided at compile time and +// silently produced a do-nothing bridge whenever a header search path was +// slightly off. + +#import + +typedef void (*UnrealMessageCallback)(const char* target, + const char* method, + const char* data); + +typedef void (*UnrealBinaryCallback)(const char* target, + const char* method, + const void* data, + int32_t length, + int32_t checksum); + +typedef void (*SetMessageCallbackFn)(UnrealMessageCallback); +typedef void (*SetBinaryCallbackFn)(UnrealBinaryCallback); +typedef void (*SendToUnrealFn)(const char*, const char*, const char*); +typedef void (*SendBinaryToUnrealFn)(const char*, const char*, const void*, int32_t, int32_t); +typedef void (*ExecuteConsoleCommandFn)(const char*); +typedef void (*LoadLevelFn)(const char*); +typedef void (*ApplyQualitySettingsFn)(int32_t, int32_t, int32_t, int32_t, + int32_t, int32_t, int32_t, int32_t); +typedef int32_t (*GetQualitySettingsFn)(int32_t*, int32_t); +typedef void (*PauseFn)(int32_t); +typedef void (*StopFn)(void); +typedef int32_t (*IsReadyFn)(void); + +/// Look a bridge symbol up in whatever is already loaded into the process. +/// Returns NULL when UnrealFramework is not present. +static void* UnrealSymbol(const char* name) { + return dlsym(RTLD_DEFAULT, name); } -// ============================================================ -// MARK: - UnrealBridge Implementation (with Unreal Framework) -// ============================================================ - -@interface UnrealBridge : NSObject -+ (UnrealBridge*)shared; -- (BOOL)createWithConfig:(NSDictionary*)config controller:(id)controller; -- (UIView*)getView; -- (void)pause; -- (void)resume; -- (void)quit; -- (void)sendMessageWithTarget:(NSString*)target method:(NSString*)method data:(NSString*)data; -- (void)executeConsoleCommand:(NSString*)command; -- (void)loadLevel:(NSString*)levelName; -- (void)applyQualitySettings:(NSDictionary*)settings; -- (NSDictionary*)getQualitySettings; -@end - -@implementation UnrealBridge +#define UNREAL_FN(type, name) ((type)UnrealSymbol(name)) -+ (UnrealBridge*)shared { - static UnrealBridge* instance = nil; +/// Whether UnrealFramework is loaded into this process. +static BOOL UnrealFrameworkLinked(void) { + static BOOL linked = NO; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ - instance = [[UnrealBridge alloc] init]; + linked = UnrealSymbol("UnrealBridge_SendToUnreal") != NULL; }); - return instance; -} - -- (BOOL)createWithConfig:(NSDictionary*)config controller:(id)controller { - NSLog(@"[UnrealBridge] create called with config"); - - // Store controller reference - GUnrealEngineController = controller; - - // Unreal Engine initialization happens automatically when framework loads - NSLog(@"[UnrealBridge] Unreal Engine initialized, controller registered"); - - return YES; -} - -- (UIView*)getView { - NSLog(@"[UnrealBridge] getView called"); - // On iOS, Unreal Engine manages its own view hierarchy - // Return nil - the view is handled by Unreal's window - return nil; -} - -- (void)pause { - NSLog(@"[UnrealBridge] pause called"); - if (GFlutterBridgeInstance) { - GFlutterBridgeInstance->OnEnginePause(); - } -} - -- (void)resume { - NSLog(@"[UnrealBridge] resume called"); - if (GFlutterBridgeInstance) { - GFlutterBridgeInstance->OnEngineResume(); - } -} - -- (void)quit { - NSLog(@"[UnrealBridge] quit called"); - if (GFlutterBridgeInstance) { - GFlutterBridgeInstance->OnEngineQuit(); - } - GUnrealEngineController = nil; - GFlutterBridgeInstance = nullptr; + return linked; } -- (void)sendMessageWithTarget:(NSString*)target method:(NSString*)method data:(NSString*)data { - NSLog(@"[UnrealBridge] sendMessage: Target=%@, Method=%@", target, method); - - if (GFlutterBridgeInstance) { - FString TargetString = NSStringToFString(target); - FString MethodString = NSStringToFString(method); - FString DataString = NSStringToFString(data); - GFlutterBridgeInstance->ReceiveFromFlutter(TargetString, MethodString, DataString); - } else { - NSLog(@"[UnrealBridge] Warning: FlutterBridge instance not set"); - } -} +/// Matches UNREALBRIDGE_QUALITY_VALUE_COUNT in the plugin header. +static const int32_t kUnrealQualityValueCount = 7; -- (void)executeConsoleCommand:(NSString*)command { - NSLog(@"[UnrealBridge] executeConsoleCommand: %@", command); - if (GFlutterBridgeInstance) { - GFlutterBridgeInstance->ExecuteConsoleCommand(NSStringToFString(command)); - } +/// Keys for the quality values, in the order the framework writes them. +static NSArray* UnrealQualityKeys(void) { + return @[ @"antiAliasing", @"shadow", @"postProcess", @"texture", + @"effects", @"foliage", @"viewDistance" ]; } -- (void)loadLevel:(NSString*)levelName { - NSLog(@"[UnrealBridge] loadLevel: %@", levelName); - if (GFlutterBridgeInstance) { - GFlutterBridgeInstance->LoadLevel(NSStringToFString(levelName)); - } -} - -- (void)applyQualitySettings:(NSDictionary*)settings { - NSLog(@"[UnrealBridge] applyQualitySettings called"); - if (!GFlutterBridgeInstance) return; - - TMap SettingsMap = NSDictionaryToTMap(settings); - - int32 QualityLevel = SettingsMap.Contains(TEXT("qualityLevel")) ? FCString::Atoi(*SettingsMap[TEXT("qualityLevel")]) : -1; - int32 AntiAliasing = SettingsMap.Contains(TEXT("antiAliasingQuality")) ? FCString::Atoi(*SettingsMap[TEXT("antiAliasingQuality")]) : -1; - int32 Shadow = SettingsMap.Contains(TEXT("shadowQuality")) ? FCString::Atoi(*SettingsMap[TEXT("shadowQuality")]) : -1; - int32 PostProcess = SettingsMap.Contains(TEXT("postProcessQuality")) ? FCString::Atoi(*SettingsMap[TEXT("postProcessQuality")]) : -1; - int32 Texture = SettingsMap.Contains(TEXT("textureQuality")) ? FCString::Atoi(*SettingsMap[TEXT("textureQuality")]) : -1; - int32 Effects = SettingsMap.Contains(TEXT("effectsQuality")) ? FCString::Atoi(*SettingsMap[TEXT("effectsQuality")]) : -1; - int32 Foliage = SettingsMap.Contains(TEXT("foliageQuality")) ? FCString::Atoi(*SettingsMap[TEXT("foliageQuality")]) : -1; - int32 ViewDistance = SettingsMap.Contains(TEXT("viewDistanceQuality")) ? FCString::Atoi(*SettingsMap[TEXT("viewDistanceQuality")]) : -1; - - GFlutterBridgeInstance->ApplyQualitySettings(QualityLevel, AntiAliasing, Shadow, PostProcess, Texture, Effects, Foliage, ViewDistance); -} - -- (NSDictionary*)getQualitySettings { - NSLog(@"[UnrealBridge] getQualitySettings called"); - if (!GFlutterBridgeInstance) return @{}; - return TMapToNSDictionary(GFlutterBridgeInstance->GetQualitySettings()); -} - -@end +// The Swift controller. Held strongly for as long as the bridge is live. +static id GUnrealEngineController = nil; // ============================================================ -// MARK: - C++ Interface for Unreal Engine Callbacks +// MARK: - Callbacks from Unreal // ============================================================ +// +// These fire on Unreal's GAME thread, and their pointers are only valid for the +// duration of the call. Copy into Foundation objects immediately, then hop to +// the main queue before touching the controller. -void FlutterBridge_SendToFlutter_iOS(const FString& Target, const FString& Method, const FString& Data) -{ - NSString* nsTarget = FStringToNSString(Target); - NSString* nsMethod = FStringToNSString(Method); - NSString* nsData = FStringToNSString(Data); +static void HandleUnrealMessage(const char* target, const char* method, const char* data) { + NSString* nsTarget = target ? @(target) : @""; + NSString* nsMethod = method ? @(method) : @""; + NSString* nsData = data ? @(data) : @""; dispatch_async(dispatch_get_main_queue(), ^{ - if (GUnrealEngineController) { - SEL selector = NSSelectorFromString(@"onMessageFromUnrealWithTarget:method:data:"); - if ([GUnrealEngineController respondsToSelector:selector]) { - NSMethodSignature* sig = [GUnrealEngineController methodSignatureForSelector:selector]; + id controller = GUnrealEngineController; + if (!controller) { + NSLog(@"[UnrealBridge] Dropping message, no controller: %@.%@", nsTarget, nsMethod); + return; + } + + // Level loads arrive on the message channel rather than a channel of + // their own. Route them to the controller's level callback so the + // existing Swift signature keeps working. + if ([nsTarget isEqualToString:@"FlutterBridge"] && + [nsMethod isEqualToString:@"onLevelLoaded"]) { + SEL levelSelector = NSSelectorFromString(@"onLevelLoadedWithLevelName:buildIndex:"); + if ([controller respondsToSelector:levelSelector]) { + NSMethodSignature* sig = [controller methodSignatureForSelector:levelSelector]; NSInvocation* inv = [NSInvocation invocationWithMethodSignature:sig]; - [inv setTarget:GUnrealEngineController]; - [inv setSelector:selector]; - [inv setArgument:&nsTarget atIndex:2]; - [inv setArgument:&nsMethod atIndex:3]; - [inv setArgument:&nsData atIndex:4]; + [inv setTarget:controller]; + [inv setSelector:levelSelector]; + NSString* levelName = nsData; + NSInteger buildIndex = 0; + [inv setArgument:&levelName atIndex:2]; + [inv setArgument:&buildIndex atIndex:3]; [inv invoke]; - } else { - NSLog(@"[UnrealBridge] Controller doesn't respond to onMessageFromUnrealWithTarget:method:data:"); + return; } - } else { - NSLog(@"[UnrealBridge] Warning: Controller not set"); } - }); - UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_iOS] Message sent to Flutter: Target=%s, Method=%s"), *Target, *Method); + SEL selector = NSSelectorFromString(@"onMessageFromUnrealWithTarget:method:data:"); + if (![controller respondsToSelector:selector]) { + NSLog(@"[UnrealBridge] Controller does not respond to onMessageFromUnrealWithTarget:method:data:"); + return; + } + + NSMethodSignature* sig = [controller methodSignatureForSelector:selector]; + NSInvocation* inv = [NSInvocation invocationWithMethodSignature:sig]; + [inv setTarget:controller]; + [inv setSelector:selector]; + NSString* t = nsTarget; NSString* m = nsMethod; NSString* d = nsData; + [inv setArgument:&t atIndex:2]; + [inv setArgument:&m atIndex:3]; + [inv setArgument:&d atIndex:4]; + [inv invoke]; + }); } -void FlutterBridge_NotifyLevelLoaded_iOS(const FString& LevelName, int32 BuildIndex) -{ - NSString* nsLevelName = FStringToNSString(LevelName); - NSNumber* nsBuildIndex = @(BuildIndex); +static void HandleUnrealBinary(const char* target, const char* method, + const void* data, int32_t length, int32_t checksum) { + NSString* nsTarget = target ? @(target) : @""; + NSString* nsMethod = method ? @(method) : @""; + NSData* nsData = (data && length > 0) + ? [NSData dataWithBytes:data length:(NSUInteger)length] + : [NSData data]; dispatch_async(dispatch_get_main_queue(), ^{ - if (GUnrealEngineController) { - SEL selector = NSSelectorFromString(@"onLevelLoadedWithLevelName:buildIndex:"); - if ([GUnrealEngineController respondsToSelector:selector]) { - NSMethodSignature* sig = [GUnrealEngineController methodSignatureForSelector:selector]; - NSInvocation* inv = [NSInvocation invocationWithMethodSignature:sig]; - [inv setTarget:GUnrealEngineController]; - [inv setSelector:selector]; - [inv setArgument:&nsLevelName atIndex:2]; - NSInteger buildIndexVal = [nsBuildIndex integerValue]; - [inv setArgument:&buildIndexVal atIndex:3]; - [inv invoke]; - } + id controller = GUnrealEngineController; + if (!controller) { + NSLog(@"[UnrealBridge] Dropping binary, no controller: %@.%@", nsTarget, nsMethod); + return; } - }); - UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_iOS] Level loaded: %s"), *LevelName); -} + SEL selector = NSSelectorFromString(@"onBinaryFromUnrealWithTarget:method:data:checksum:"); + if (![controller respondsToSelector:selector]) { + NSLog(@"[UnrealBridge] Controller has no binary handler, dropping %lu bytes from %@.%@", + (unsigned long)nsData.length, nsTarget, nsMethod); + return; + } -void FlutterBridge_SetInstance_iOS(AFlutterBridge* Instance) -{ - GFlutterBridgeInstance = Instance; - UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_iOS] FlutterBridge instance set")); + NSMethodSignature* sig = [controller methodSignatureForSelector:selector]; + NSInvocation* inv = [NSInvocation invocationWithMethodSignature:sig]; + [inv setTarget:controller]; + [inv setSelector:selector]; + NSString* t = nsTarget; NSString* m = nsMethod; NSData* d = nsData; + NSInteger c = (NSInteger)checksum; + [inv setArgument:&t atIndex:2]; + [inv setArgument:&m atIndex:3]; + [inv setArgument:&d atIndex:4]; + [inv setArgument:&c atIndex:5]; + [inv invoke]; + }); } -#else // ============================================================ -// MARK: - Stub Implementation (UnrealFramework not available) +// MARK: - UnrealBridge // ============================================================ -#import -#import - -// Reference to controller for stub mode -static id GUnrealEngineController = nil; - @interface UnrealBridge : NSObject + (UnrealBridge*)shared; - (BOOL)createWithConfig:(NSDictionary*)config controller:(id)controller; @@ -261,6 +184,7 @@ - (void)pause; - (void)resume; - (void)quit; - (void)sendMessageWithTarget:(NSString*)target method:(NSString*)method data:(NSString*)data; +- (void)sendBinaryWithTarget:(NSString*)target method:(NSString*)method data:(NSData*)data; - (void)executeConsoleCommand:(NSString*)command; - (void)loadLevel:(NSString*)levelName; - (void)applyQualitySettings:(NSDictionary*)settings; @@ -279,51 +203,123 @@ + (UnrealBridge*)shared { } - (BOOL)createWithConfig:(NSDictionary*)config controller:(id)controller { - NSLog(@"[UnrealBridge] Stub: create called (UnrealFramework not available)"); + if (!UnrealFrameworkLinked()) { + NSLog(@"[UnrealBridge] UnrealFramework is not linked into this app. " + @"Run 'game export unreal -p ios' and 'game sync unreal -p ios', " + @"and check the framework is embedded in the Xcode target."); + return NO; + } + GUnrealEngineController = controller; - // Return NO to indicate Unreal is not actually available - return NO; + + SetMessageCallbackFn setMessage = UNREAL_FN(SetMessageCallbackFn, "UnrealBridge_SetMessageCallback"); + if (setMessage) setMessage(&HandleUnrealMessage); + + SetBinaryCallbackFn setBinary = UNREAL_FN(SetBinaryCallbackFn, "UnrealBridge_SetBinaryCallback"); + if (setBinary) setBinary(&HandleUnrealBinary); + + IsReadyFn isReady = UNREAL_FN(IsReadyFn, "UnrealBridge_IsReady"); + const BOOL engineReady = isReady && (isReady() != 0); + if (!engineReady) { + // The framework is linked but no AFlutterBridge actor has registered + // yet. That is normal this early: the actor registers in BeginPlay. + // Calls made before then are dropped by the framework, not by us. + NSLog(@"[UnrealBridge] Framework linked, waiting for AFlutterBridge actor. " + @"Place one in your level if messages never arrive."); + } + + NSLog(@"[UnrealBridge] Bridge created, callbacks registered"); + return YES; } - (UIView*)getView { - NSLog(@"[UnrealBridge] Stub: getView called (UnrealFramework not available)"); + // Unreal owns its own window on iOS; there is no subview to hand back. return nil; } - (void)pause { - NSLog(@"[UnrealBridge] Stub: pause called (UnrealFramework not available)"); + PauseFn pause = UNREAL_FN(PauseFn, "UnrealBridge_Pause"); + if (pause) pause(1); } - (void)resume { - NSLog(@"[UnrealBridge] Stub: resume called (UnrealFramework not available)"); + PauseFn pause = UNREAL_FN(PauseFn, "UnrealBridge_Pause"); + if (pause) pause(0); } - (void)quit { - NSLog(@"[UnrealBridge] Stub: quit called (UnrealFramework not available)"); + StopFn stop = UNREAL_FN(StopFn, "UnrealBridge_Stop"); + if (stop) stop(); GUnrealEngineController = nil; } - (void)sendMessageWithTarget:(NSString*)target method:(NSString*)method data:(NSString*)data { - NSLog(@"[UnrealBridge] Stub: sendMessage called (UnrealFramework not available)"); + SendToUnrealFn send = UNREAL_FN(SendToUnrealFn, "UnrealBridge_SendToUnreal"); + if (!send) { + NSLog(@"[UnrealBridge] Cannot send, framework not loaded"); + return; + } + send(target.UTF8String, method.UTF8String, data.UTF8String ?: ""); +} + +- (void)sendBinaryWithTarget:(NSString*)target method:(NSString*)method data:(NSData*)data { + SendBinaryToUnrealFn send = UNREAL_FN(SendBinaryToUnrealFn, "UnrealBridge_SendBinaryToUnreal"); + if (!send) { + NSLog(@"[UnrealBridge] Cannot send binary, framework not loaded"); + return; + } + // Checksum is computed engine-side on receipt; 0 means "unset". + send(target.UTF8String, method.UTF8String, data.bytes, (int32_t)data.length, 0); } - (void)executeConsoleCommand:(NSString*)command { - NSLog(@"[UnrealBridge] Stub: executeConsoleCommand called (UnrealFramework not available)"); + ExecuteConsoleCommandFn exec = UNREAL_FN(ExecuteConsoleCommandFn, "UnrealBridge_ExecuteConsoleCommand"); + if (exec) exec(command.UTF8String); } - (void)loadLevel:(NSString*)levelName { - NSLog(@"[UnrealBridge] Stub: loadLevel called (UnrealFramework not available)"); + LoadLevelFn load = UNREAL_FN(LoadLevelFn, "UnrealBridge_LoadLevel"); + if (load) load(levelName.UTF8String); } - (void)applyQualitySettings:(NSDictionary*)settings { - NSLog(@"[UnrealBridge] Stub: applyQualitySettings called (UnrealFramework not available)"); + ApplyQualitySettingsFn apply = UNREAL_FN(ApplyQualitySettingsFn, "UnrealBridge_ApplyQualitySettings"); + if (!apply) return; + + int32_t (^value)(NSString*) = ^int32_t(NSString* key) { + id v = settings[key]; + return v ? (int32_t)[v intValue] : -1; + }; + + apply( + value(@"qualityLevel"), + value(@"antiAliasingQuality"), + value(@"shadowQuality"), + value(@"postProcessQuality"), + value(@"textureQuality"), + value(@"effectsQuality"), + value(@"foliageQuality"), + value(@"viewDistanceQuality")); } - (NSDictionary*)getQualitySettings { - NSLog(@"[UnrealBridge] Stub: getQualitySettings called (UnrealFramework not available)"); - return @{}; + GetQualitySettingsFn get = UNREAL_FN(GetQualitySettingsFn, "UnrealBridge_GetQualitySettings"); + if (!get) return @{}; + + int32_t values[kUnrealQualityValueCount]; + const int32_t written = get(values, kUnrealQualityValueCount); + if (written < kUnrealQualityValueCount) { + // The framework serves a cache refreshed on the game thread, so the + // very first call can land before it is populated. + return @{}; + } + + NSArray* keys = UnrealQualityKeys(); + NSMutableDictionary* result = [NSMutableDictionary dictionaryWithCapacity:keys.count]; + for (NSUInteger i = 0; i < keys.count; i++) { + result[keys[i]] = @(values[i]); + } + return result; } @end - -#endif // __has_include("FlutterBridge.h") diff --git a/engines/unreal/dart/ios/Tests/MockUnrealFramework.c b/engines/unreal/dart/ios/Tests/MockUnrealFramework.c new file mode 100644 index 0000000..797bdab --- /dev/null +++ b/engines/unreal/dart/ios/Tests/MockUnrealFramework.c @@ -0,0 +1,49 @@ +// Mock UnrealFramework: exports the C ABI so the pod's dlsym path can be +// exercised without an engine build. +#include +#include +#include + +typedef void (*UnrealMessageCallback)(const char*, const char*, const char*); +typedef void (*UnrealBinaryCallback)(const char*, const char*, const void*, int32_t, int32_t); + +static UnrealMessageCallback gMessage = 0; +static UnrealBinaryCallback gBinary = 0; + +char gLastTarget[128], gLastMethod[128], gLastData[256]; +int32_t gLastQuality[8]; +int gConsoleCalls = 0, gLevelCalls = 0, gPauseState = -1, gStopped = 0; + +void UnrealBridge_SetMessageCallback(UnrealMessageCallback cb) { gMessage = cb; } +void UnrealBridge_SetBinaryCallback(UnrealBinaryCallback cb) { gBinary = cb; } +void UnrealBridge_SendToUnreal(const char* t, const char* m, const char* d) { + snprintf(gLastTarget, sizeof gLastTarget, "%s", t ? t : ""); + snprintf(gLastMethod, sizeof gLastMethod, "%s", m ? m : ""); + snprintf(gLastData, sizeof gLastData, "%s", d ? d : ""); +} +void UnrealBridge_SendBinaryToUnreal(const char* t, const char* m, const void* d, int32_t n, int32_t c) { + (void)d; (void)c; + snprintf(gLastTarget, sizeof gLastTarget, "%s", t ? t : ""); + snprintf(gLastMethod, sizeof gLastMethod, "%s", m ? m : ""); + snprintf(gLastData, sizeof gLastData, "%d", n); +} +void UnrealBridge_ExecuteConsoleCommand(const char* c) { (void)c; gConsoleCalls++; } +void UnrealBridge_LoadLevel(const char* l) { (void)l; gLevelCalls++; } +void UnrealBridge_ApplyQualitySettings(int32_t a,int32_t b,int32_t c,int32_t d, + int32_t e,int32_t f,int32_t g,int32_t h) { + gLastQuality[0]=a; gLastQuality[1]=b; gLastQuality[2]=c; gLastQuality[3]=d; + gLastQuality[4]=e; gLastQuality[5]=f; gLastQuality[6]=g; gLastQuality[7]=h; +} +int32_t UnrealBridge_GetQualitySettings(int32_t* out, int32_t cap) { + if (!out || cap < 7) return 0; + for (int i = 0; i < 7; i++) out[i] = i + 1; + return 7; +} +void UnrealBridge_Pause(int32_t p) { gPauseState = p; } +void UnrealBridge_Stop(void) { gStopped = 1; } +int32_t UnrealBridge_IsReady(void) { return 1; } + +/// Drive a message from "Unreal" back into the pod. +void MockUnreal_FireMessage(const char* t, const char* m, const char* d) { + if (gMessage) gMessage(t, m, d); +} diff --git a/engines/unreal/dart/ios/Tests/UnrealBridgeAbsentTests.mm b/engines/unreal/dart/ios/Tests/UnrealBridgeAbsentTests.mm new file mode 100644 index 0000000..5b63515 --- /dev/null +++ b/engines/unreal/dart/ios/Tests/UnrealBridgeAbsentTests.mm @@ -0,0 +1,41 @@ +#import +#import +#import + +int main(void) { + @autoreleasepool { + Class cls = NSClassFromString(@"UnrealBridge"); + if (!cls) { printf("FAIL: UnrealBridge class not found\n"); return 1; } + printf("PASS: NSClassFromString found UnrealBridge\n"); + + id shared = ((id(*)(id, SEL))objc_msgSend)(cls, NSSelectorFromString(@"shared")); + if (!shared) { printf("FAIL: shared returned nil\n"); return 1; } + printf("PASS: shared instance created\n"); + + BOOL created = ((BOOL(*)(id, SEL, id, id))objc_msgSend)( + shared, NSSelectorFromString(@"createWithConfig:controller:"), @{}, (id)shared); + printf("%s: createWithConfig returned %s with framework absent\n", + created ? "FAIL" : "PASS", created ? "YES" : "NO"); + if (created) return 1; + + // Everything below must be a safe no-op, not a crash. + ((void(*)(id, SEL, id, id, id))objc_msgSend)( + shared, NSSelectorFromString(@"sendMessageWithTarget:method:data:"), @"T", @"M", @"{}"); + ((void(*)(id, SEL, id))objc_msgSend)( + shared, NSSelectorFromString(@"executeConsoleCommand:"), @"stat fps"); + ((void(*)(id, SEL, id))objc_msgSend)( + shared, NSSelectorFromString(@"loadLevel:"), @"Main"); + ((void(*)(id, SEL, id))objc_msgSend)( + shared, NSSelectorFromString(@"applyQualitySettings:"), @{@"qualityLevel": @3}); + ((void(*)(id, SEL))objc_msgSend)(shared, NSSelectorFromString(@"pause")); + ((void(*)(id, SEL))objc_msgSend)(shared, NSSelectorFromString(@"resume")); + + id q = ((id(*)(id, SEL))objc_msgSend)(shared, NSSelectorFromString(@"getQualitySettings")); + printf("%s: getQualitySettings returned %lu keys (expected 0)\n", + ([q count] == 0) ? "PASS" : "FAIL", (unsigned long)[q count]); + + ((void(*)(id, SEL))objc_msgSend)(shared, NSSelectorFromString(@"quit")); + printf("PASS: all bridge calls survived with no framework loaded\n"); + return 0; + } +} diff --git a/engines/unreal/dart/ios/Tests/UnrealBridgeTests.mm b/engines/unreal/dart/ios/Tests/UnrealBridgeTests.mm new file mode 100644 index 0000000..c9dd2d0 --- /dev/null +++ b/engines/unreal/dart/ios/Tests/UnrealBridgeTests.mm @@ -0,0 +1,99 @@ +#import +#import +#import + +extern "C" { +void MockUnreal_FireMessage(const char*, const char*, const char*); +extern char gLastTarget[128], gLastMethod[128], gLastData[256]; +extern int32_t gLastQuality[8]; +extern int gConsoleCalls, gLevelCalls, gPauseState, gStopped; +} + +static int gFailures = 0; +static void check(bool ok, const char* what) { + printf("%s: %s\n", ok ? "PASS" : "FAIL", what); + if (!ok) gFailures++; +} + +// Stand-in for the Swift controller. +@interface FakeController : NSObject +@property (nonatomic, copy) NSString* gotTarget; +@property (nonatomic, copy) NSString* gotMethod; +@property (nonatomic, copy) NSString* gotData; +@property (nonatomic, copy) NSString* gotLevel; +@end +@implementation FakeController +- (void)onMessageFromUnrealWithTarget:(NSString*)t method:(NSString*)m data:(NSString*)d { + self.gotTarget = t; self.gotMethod = m; self.gotData = d; +} +- (void)onLevelLoadedWithLevelName:(NSString*)n buildIndex:(NSInteger)i { + self.gotLevel = n; +} +@end + +int main(void) { + @autoreleasepool { + Class cls = NSClassFromString(@"UnrealBridge"); + id bridge = ((id(*)(id, SEL))objc_msgSend)(cls, NSSelectorFromString(@"shared")); + FakeController* controller = [FakeController new]; + + BOOL created = ((BOOL(*)(id, SEL, id, id))objc_msgSend)( + bridge, NSSelectorFromString(@"createWithConfig:controller:"), @{}, controller); + check(created, "createWithConfig succeeds when the framework is loaded"); + + // Flutter -> Unreal + ((void(*)(id, SEL, id, id, id))objc_msgSend)( + bridge, NSSelectorFromString(@"sendMessageWithTarget:method:data:"), + @"GameManager", @"startGame", @"{\"level\":1}"); + check(strcmp(gLastTarget, "GameManager") == 0 && + strcmp(gLastMethod, "startGame") == 0 && + strcmp(gLastData, "{\"level\":1}") == 0, + "sendMessage reaches the framework with target, method and data intact"); + + NSData* payload = [@"binary-payload" dataUsingEncoding:NSUTF8StringEncoding]; + ((void(*)(id, SEL, id, id, id))objc_msgSend)( + bridge, NSSelectorFromString(@"sendBinaryWithTarget:method:data:"), + @"Assets", @"upload", payload); + check(strcmp(gLastMethod, "upload") == 0 && atoi(gLastData) == (int)payload.length, + "sendBinary forwards the byte count"); + + ((void(*)(id, SEL, id))objc_msgSend)(bridge, NSSelectorFromString(@"executeConsoleCommand:"), @"stat fps"); + check(gConsoleCalls == 1, "executeConsoleCommand reaches the framework"); + + ((void(*)(id, SEL, id))objc_msgSend)(bridge, NSSelectorFromString(@"loadLevel:"), @"Arena"); + check(gLevelCalls == 1, "loadLevel reaches the framework"); + + ((void(*)(id, SEL, id))objc_msgSend)( + bridge, NSSelectorFromString(@"applyQualitySettings:"), + (@{@"qualityLevel": @3, @"shadowQuality": @2})); + check(gLastQuality[0] == 3 && gLastQuality[2] == 2 && gLastQuality[1] == -1, + "applyQualitySettings maps keys by position and defaults missing ones to -1"); + + NSDictionary* q = ((id(*)(id, SEL))objc_msgSend)(bridge, NSSelectorFromString(@"getQualitySettings")); + check([q[@"antiAliasing"] intValue] == 1 && [q[@"viewDistance"] intValue] == 7 && q.count == 7, + "getQualitySettings maps the value array back onto named keys in order"); + + ((void(*)(id, SEL))objc_msgSend)(bridge, NSSelectorFromString(@"pause")); + check(gPauseState == 1, "pause forwards 1"); + ((void(*)(id, SEL))objc_msgSend)(bridge, NSSelectorFromString(@"resume")); + check(gPauseState == 0, "resume forwards 0"); + + // Unreal -> Flutter, including the level-load reroute. + MockUnreal_FireMessage("GameManager", "onScore", "{\"score\":42}"); + MockUnreal_FireMessage("FlutterBridge", "onLevelLoaded", "Arena"); + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.3]]; + + check([controller.gotTarget isEqualToString:@"GameManager"] && + [controller.gotMethod isEqualToString:@"onScore"] && + [controller.gotData isEqualToString:@"{\"score\":42}"], + "a message from Unreal reaches the controller on the main thread"); + check([controller.gotLevel isEqualToString:@"Arena"], + "onLevelLoaded is rerouted to the controller's level callback"); + + ((void(*)(id, SEL))objc_msgSend)(bridge, NSSelectorFromString(@"quit")); + check(gStopped == 1, "quit stops the framework"); + + printf("\n%s (%d failures)\n", gFailures ? "FAILED" : "ALL PASSED", gFailures); + return gFailures ? 1 : 0; + } +} diff --git a/engines/unreal/dart/ios/Tests/run_bridge_tests.sh b/engines/unreal/dart/ios/Tests/run_bridge_tests.sh new file mode 100755 index 0000000..0d0f0ac --- /dev/null +++ b/engines/unreal/dart/ios/Tests/run_bridge_tests.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# +# Exercise UnrealBridge.mm in the iOS Simulator, both with and without +# UnrealFramework present. +# +# There is no Unreal build in CI, so the "present" case runs against +# MockUnrealFramework.c, which exports the same C ABI the plugin's +# Public/UnrealBridge.h declares. That covers the pod side and the ABI contract +# in both directions. It does NOT cover the engine-side implementation in +# FlutterBridge_IOS.cpp, which needs a real engine to build. +# +# Usage: ./run_bridge_tests.sh [simulator-udid] + +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SRC="$HERE/../Classes/UnrealBridge.mm" +BUILD="$(mktemp -d)" +trap 'rm -rf "$BUILD"' EXIT + +TARGET="arm64-apple-ios15.0-simulator" +SDK="iphonesimulator" + +UDID="${1:-}" +if [ -z "$UDID" ]; then + UDID=$(xcrun simctl list devices available \ + | grep -oE '\(([0-9A-F-]{36})\)' | head -1 | tr -d '()') +fi +if [ -z "$UDID" ]; then + echo "No iOS simulator available" >&2 + exit 1 +fi + +BOOTED_HERE=0 +if ! xcrun simctl list devices booted | grep -q "$UDID"; then + xcrun simctl boot "$UDID" + xcrun simctl bootstatus "$UDID" -b >/dev/null + BOOTED_HERE=1 +fi +cleanup_sim() { + [ "$BOOTED_HERE" -eq 1 ] && xcrun simctl shutdown "$UDID" >/dev/null 2>&1 || true +} +trap 'cleanup_sim; rm -rf "$BUILD"' EXIT + +compile_mm() { + xcrun --sdk "$SDK" clang++ -target "$TARGET" -fobjc-arc \ + -x objective-c++ -std=c++17 -Wall -Wextra -c "$1" -o "$2" +} + +echo "Building bridge..." +compile_mm "$SRC" "$BUILD/UnrealBridge.o" + +echo "Building mock framework..." +xcrun --sdk "$SDK" clang -target "$TARGET" -dynamiclib \ + -install_name @rpath/MockUnreal.dylib \ + "$HERE/MockUnrealFramework.c" -o "$BUILD/MockUnreal.dylib" + +echo +echo "=== Framework absent ===" +compile_mm "$HERE/UnrealBridgeAbsentTests.mm" "$BUILD/absent.o" +xcrun --sdk "$SDK" clang++ -target "$TARGET" \ + "$BUILD/UnrealBridge.o" "$BUILD/absent.o" \ + -framework Foundation -framework UIKit -o "$BUILD/absent" +xcrun simctl spawn "$UDID" "$BUILD/absent" 2>&1 | grep -v '^20[0-9][0-9]-' + +echo +echo "=== Framework present (mock) ===" +compile_mm "$HERE/UnrealBridgeTests.mm" "$BUILD/live.o" +xcrun --sdk "$SDK" clang++ -target "$TARGET" \ + "$BUILD/UnrealBridge.o" "$BUILD/live.o" "$BUILD/MockUnreal.dylib" \ + -framework Foundation -framework UIKit -rpath "$BUILD" -o "$BUILD/live" +xcrun simctl spawn "$UDID" "$BUILD/live" 2>&1 | grep -v '^20[0-9][0-9]-' diff --git a/engines/unreal/dart/macos/Classes/UnrealBridge.h b/engines/unreal/dart/macos/Classes/UnrealBridge.h new file mode 100644 index 0000000..ade90eb --- /dev/null +++ b/engines/unreal/dart/macos/Classes/UnrealBridge.h @@ -0,0 +1,58 @@ +// +// UnrealBridge.h +// gameframework_unreal (macOS) +// +// Objective-C face of the UnrealFramework bridge. +// +// This header exists so Swift can call the bridge directly. The iOS pod +// reaches its equivalent class through NSClassFromString and NSInvocation +// because it has no header to import; declaring the interface here is the +// same thing without the reflection. If the two pods are ever merged into a +// shared darwin/ directory, this is the shape to keep. +// +// NS_SWIFT_NAME keeps the Swift call sites reading naturally, so +// UnrealEngineController can say UnrealBridge.shared.create(config:controller:) +// rather than createWithConfig(_:controller:). +// + +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface UnrealBridge : NSObject + +@property (class, nonatomic, readonly) UnrealBridge *shared; + +/// Register the controller and wire up callbacks from Unreal. +/// Returns NO when UnrealFramework is not loaded into this process. +- (BOOL)createWithConfig:(NSDictionary *)config + controller:(id)controller NS_SWIFT_NAME(create(config:controller:)); + +/// Unreal owns its own window on macOS, so this is always nil today. It stays +/// on the interface because the controller's view plumbing expects it. +- (nullable NSView *)getView; + +- (void)pause; +- (void)resume; +- (void)quit; + +- (void)sendMessageWithTarget:(NSString *)target + method:(NSString *)method + data:(NSString *)data NS_SWIFT_NAME(sendMessage(target:method:data:)); + +- (void)sendBinaryWithTarget:(NSString *)target + method:(NSString *)method + data:(NSData *)data NS_SWIFT_NAME(sendBinary(target:method:data:)); + +- (void)executeConsoleCommand:(NSString *)command; + +- (void)loadLevel:(NSString *)levelName; + +- (void)applyQualitySettings:(NSDictionary *)settings; + +- (NSDictionary *)getQualitySettings; + +@end + +NS_ASSUME_NONNULL_END diff --git a/engines/unreal/dart/macos/Classes/UnrealBridge.mm b/engines/unreal/dart/macos/Classes/UnrealBridge.mm index d44c96f..1880910 100644 --- a/engines/unreal/dart/macos/Classes/UnrealBridge.mm +++ b/engines/unreal/dart/macos/Classes/UnrealBridge.mm @@ -1,92 +1,185 @@ // Copyright Epic Games, Inc. All Rights Reserved. - -#import "UnrealEngineController.swift" -#include "FlutterBridge.h" - -#if PLATFORM_MAC +// +// Sibling of ios/Classes/UnrealBridge.mm. The two differ only in AppKit versus +// UIKit and in how the class is exposed to Swift: macOS declares it in +// UnrealBridge.h so the controller can call it directly, while iOS goes through +// NSClassFromString. Keep behaviour changes in step across both. #import #import - -// Reference to FlutterBridge instance -static AFlutterBridge* GFlutterBridgeInstance = nullptr; - -// Reference to UnrealEngineController -static UnrealEngineController* GUnrealEngineController = nullptr; +#import "UnrealBridge.h" // ============================================================ -// MARK: - Helper Functions +// MARK: - UnrealFramework C ABI // ============================================================ +// +// Resolved with dlsym rather than linked. The canonical declarations live in +// the plugin's Public/UnrealBridge.h and are copied into the framework's +// Headers/ at export time; keep the signatures below in step with them. +// +// Why dlsym and not a link-time dependency: UnrealFramework is produced by +// "game export unreal -p ios", so it may legitimately be absent when this pod +// is built. Linking against it would break those builds, and weak_import does +// not help, because it only makes a symbol optional at load time while the +// static linker still demands a definition. Looking the symbols up at runtime +// keeps the pod self-contained and turns "framework missing" into a clear log +// line instead of a build failure. +// +// This replaces an older __has_include split that decided at compile time and +// silently produced a do-nothing bridge whenever a header search path was +// slightly off. + +#import + +typedef void (*UnrealMessageCallback)(const char* target, + const char* method, + const char* data); + +typedef void (*UnrealBinaryCallback)(const char* target, + const char* method, + const void* data, + int32_t length, + int32_t checksum); + +typedef void (*SetMessageCallbackFn)(UnrealMessageCallback); +typedef void (*SetBinaryCallbackFn)(UnrealBinaryCallback); +typedef void (*SendToUnrealFn)(const char*, const char*, const char*); +typedef void (*SendBinaryToUnrealFn)(const char*, const char*, const void*, int32_t, int32_t); +typedef void (*ExecuteConsoleCommandFn)(const char*); +typedef void (*LoadLevelFn)(const char*); +typedef void (*ApplyQualitySettingsFn)(int32_t, int32_t, int32_t, int32_t, + int32_t, int32_t, int32_t, int32_t); +typedef int32_t (*GetQualitySettingsFn)(int32_t*, int32_t); +typedef void (*PauseFn)(int32_t); +typedef void (*StopFn)(void); +typedef int32_t (*IsReadyFn)(void); + +/// Look a bridge symbol up in whatever is already loaded into the process. +/// Returns NULL when UnrealFramework is not present. +static void* UnrealSymbol(const char* name) { + return dlsym(RTLD_DEFAULT, name); +} + +#define UNREAL_FN(type, name) ((type)UnrealSymbol(name)) -/** - * Convert FString to NSString - */ -NSString* FStringToNSString(const FString& String) -{ - return [NSString stringWithUTF8String:TCHAR_TO_UTF8(*String)]; +/// Whether UnrealFramework is loaded into this process. +static BOOL UnrealFrameworkLinked(void) { + static BOOL linked = NO; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + linked = UnrealSymbol("UnrealBridge_SendToUnreal") != NULL; + }); + return linked; } -/** - * Convert NSString to FString - */ -FString NSStringToFString(NSString* String) -{ - if (!String) - { - return FString(); - } - return FString(UTF8_TO_TCHAR([String UTF8String])); +/// Matches UNREALBRIDGE_QUALITY_VALUE_COUNT in the plugin header. +static const int32_t kUnrealQualityValueCount = 7; + +/// Keys for the quality values, in the order the framework writes them. +static NSArray* UnrealQualityKeys(void) { + return @[ @"antiAliasing", @"shadow", @"postProcess", @"texture", + @"effects", @"foliage", @"viewDistance" ]; } -/** - * Convert NSDictionary to TMap - */ -TMap NSDictionaryToTMap(NSDictionary* Dictionary) -{ - TMap Result; +// The Swift controller. Held strongly for as long as the bridge is live. +static id GUnrealEngineController = nil; - if (!Dictionary) - { - return Result; - } +// ============================================================ +// MARK: - Callbacks from Unreal +// ============================================================ +// +// These fire on Unreal's GAME thread, and their pointers are only valid for the +// duration of the call. Copy into Foundation objects immediately, then hop to +// the main queue before touching the controller. + +static void HandleUnrealMessage(const char* target, const char* method, const char* data) { + NSString* nsTarget = target ? @(target) : @""; + NSString* nsMethod = method ? @(method) : @""; + NSString* nsData = data ? @(data) : @""; - for (NSString* key in Dictionary) - { - NSString* value = [Dictionary objectForKey:key]; - if ([value isKindOfClass:[NSString class]]) - { - Result.Add(NSStringToFString(key), NSStringToFString(value)); + dispatch_async(dispatch_get_main_queue(), ^{ + id controller = GUnrealEngineController; + if (!controller) { + NSLog(@"[UnrealBridge] Dropping message, no controller: %@.%@", nsTarget, nsMethod); + return; } - else - { - // Convert other types to string - NSString* valueStr = [NSString stringWithFormat:@"%@", value]; - Result.Add(NSStringToFString(key), NSStringToFString(valueStr)); + + // Level loads arrive on the message channel rather than a channel of + // their own. Route them to the controller's level callback so the + // existing Swift signature keeps working. + if ([nsTarget isEqualToString:@"FlutterBridge"] && + [nsMethod isEqualToString:@"onLevelLoaded"]) { + SEL levelSelector = NSSelectorFromString(@"onLevelLoadedWithLevelName:buildIndex:"); + if ([controller respondsToSelector:levelSelector]) { + NSMethodSignature* sig = [controller methodSignatureForSelector:levelSelector]; + NSInvocation* inv = [NSInvocation invocationWithMethodSignature:sig]; + [inv setTarget:controller]; + [inv setSelector:levelSelector]; + NSString* levelName = nsData; + NSInteger buildIndex = 0; + [inv setArgument:&levelName atIndex:2]; + [inv setArgument:&buildIndex atIndex:3]; + [inv invoke]; + return; + } } - } - return Result; + SEL selector = NSSelectorFromString(@"onMessageFromUnrealWithTarget:method:data:"); + if (![controller respondsToSelector:selector]) { + NSLog(@"[UnrealBridge] Controller does not respond to onMessageFromUnrealWithTarget:method:data:"); + return; + } + + NSMethodSignature* sig = [controller methodSignatureForSelector:selector]; + NSInvocation* inv = [NSInvocation invocationWithMethodSignature:sig]; + [inv setTarget:controller]; + [inv setSelector:selector]; + NSString* t = nsTarget; NSString* m = nsMethod; NSString* d = nsData; + [inv setArgument:&t atIndex:2]; + [inv setArgument:&m atIndex:3]; + [inv setArgument:&d atIndex:4]; + [inv invoke]; + }); } -/** - * Convert TMap to NSDictionary - */ -NSDictionary* TMapToNSDictionary(const TMap& Map) -{ - NSMutableDictionary* Dictionary = [NSMutableDictionary dictionary]; - - for (const auto& Entry : Map) - { - NSString* key = FStringToNSString(Entry.Key); - NSNumber* value = [NSNumber numberWithInt:Entry.Value]; - [Dictionary setObject:value forKey:key]; - } +static void HandleUnrealBinary(const char* target, const char* method, + const void* data, int32_t length, int32_t checksum) { + NSString* nsTarget = target ? @(target) : @""; + NSString* nsMethod = method ? @(method) : @""; + NSData* nsData = (data && length > 0) + ? [NSData dataWithBytes:data length:(NSUInteger)length] + : [NSData data]; + + dispatch_async(dispatch_get_main_queue(), ^{ + id controller = GUnrealEngineController; + if (!controller) { + NSLog(@"[UnrealBridge] Dropping binary, no controller: %@.%@", nsTarget, nsMethod); + return; + } + + SEL selector = NSSelectorFromString(@"onBinaryFromUnrealWithTarget:method:data:checksum:"); + if (![controller respondsToSelector:selector]) { + NSLog(@"[UnrealBridge] Controller has no binary handler, dropping %lu bytes from %@.%@", + (unsigned long)nsData.length, nsTarget, nsMethod); + return; + } - return Dictionary; + NSMethodSignature* sig = [controller methodSignatureForSelector:selector]; + NSInvocation* inv = [NSInvocation invocationWithMethodSignature:sig]; + [inv setTarget:controller]; + [inv setSelector:selector]; + NSString* t = nsTarget; NSString* m = nsMethod; NSData* d = nsData; + NSInteger c = (NSInteger)checksum; + [inv setArgument:&t atIndex:2]; + [inv setArgument:&m atIndex:3]; + [inv setArgument:&d atIndex:4]; + [inv setArgument:&c atIndex:5]; + [inv invoke]; + }); } // ============================================================ -// MARK: - UnrealBridge Implementation +// MARK: - UnrealBridge // ============================================================ @implementation UnrealBridge @@ -100,250 +193,124 @@ + (UnrealBridge*)shared { return instance; } -- (BOOL)createWithConfig:(NSDictionary*)config controller:(UnrealEngineController*)controller { - NSLog(@"[UnrealBridge] create called"); +- (BOOL)createWithConfig:(NSDictionary*)config controller:(id)controller { + if (!UnrealFrameworkLinked()) { + NSLog(@"[UnrealBridge] UnrealFramework is not linked into this app. " + @"Run 'game export unreal -p macos' and 'game sync unreal -p macos', " + @"and check the framework is embedded in the Xcode target."); + return NO; + } - // Store controller reference GUnrealEngineController = controller; - // Parse config if needed - // TMap ConfigMap = NSDictionaryToTMap(config); + SetMessageCallbackFn setMessage = UNREAL_FN(SetMessageCallbackFn, "UnrealBridge_SetMessageCallback"); + if (setMessage) setMessage(&HandleUnrealMessage); - // Unreal Engine initialization happens automatically - // This is called after Unreal has already started - NSLog(@"[UnrealBridge] Unreal Engine initialized"); + SetBinaryCallbackFn setBinary = UNREAL_FN(SetBinaryCallbackFn, "UnrealBridge_SetBinaryCallback"); + if (setBinary) setBinary(&HandleUnrealBinary); + IsReadyFn isReady = UNREAL_FN(IsReadyFn, "UnrealBridge_IsReady"); + const BOOL engineReady = isReady && (isReady() != 0); + if (!engineReady) { + // The framework is linked but no AFlutterBridge actor has registered + // yet. That is normal this early: the actor registers in BeginPlay. + // Calls made before then are dropped by the framework, not by us. + NSLog(@"[UnrealBridge] Framework linked, waiting for AFlutterBridge actor. " + @"Place one in your level if messages never arrive."); + } + + NSLog(@"[UnrealBridge] Bridge created, callbacks registered"); return YES; } - (NSView*)getView { - NSLog(@"[UnrealBridge] getView called"); - - // On macOS, Unreal Engine manages its own view - // This would return the Unreal rendering view - // For now, return nil - the view is handled by Unreal's NSView + // Unreal owns its own window on macOS; there is no subview to hand back. return nil; } - (void)pause { - NSLog(@"[UnrealBridge] pause called"); - - if (GFlutterBridgeInstance) - { - GFlutterBridgeInstance->OnEnginePause(); - } - - // Pause Unreal Engine rendering - // This will be handled by Unreal's lifecycle automatically + PauseFn pause = UNREAL_FN(PauseFn, "UnrealBridge_Pause"); + if (pause) pause(1); } - (void)resume { - NSLog(@"[UnrealBridge] resume called"); - - if (GFlutterBridgeInstance) - { - GFlutterBridgeInstance->OnEngineResume(); - } - - // Resume Unreal Engine rendering - // This will be handled by Unreal's lifecycle automatically + PauseFn pause = UNREAL_FN(PauseFn, "UnrealBridge_Pause"); + if (pause) pause(0); } - (void)quit { - NSLog(@"[UnrealBridge] quit called"); - - if (GFlutterBridgeInstance) - { - GFlutterBridgeInstance->OnEngineQuit(); - } - - // Clean up references + StopFn stop = UNREAL_FN(StopFn, "UnrealBridge_Stop"); + if (stop) stop(); GUnrealEngineController = nil; - GFlutterBridgeInstance = nullptr; } - (void)sendMessageWithTarget:(NSString*)target method:(NSString*)method data:(NSString*)data { - FString TargetString = NSStringToFString(target); - FString MethodString = NSStringToFString(method); - FString DataString = NSStringToFString(data); - - NSLog(@"[UnrealBridge] sendMessage: Target=%@, Method=%@", target, method); - - if (GFlutterBridgeInstance) - { - GFlutterBridgeInstance->ReceiveFromFlutter(TargetString, MethodString, DataString); + SendToUnrealFn send = UNREAL_FN(SendToUnrealFn, "UnrealBridge_SendToUnreal"); + if (!send) { + NSLog(@"[UnrealBridge] Cannot send, framework not loaded"); + return; } - else - { - NSLog(@"[UnrealBridge] Warning: FlutterBridge instance not set"); + send(target.UTF8String, method.UTF8String, data.UTF8String ?: ""); +} + +- (void)sendBinaryWithTarget:(NSString*)target method:(NSString*)method data:(NSData*)data { + SendBinaryToUnrealFn send = UNREAL_FN(SendBinaryToUnrealFn, "UnrealBridge_SendBinaryToUnreal"); + if (!send) { + NSLog(@"[UnrealBridge] Cannot send binary, framework not loaded"); + return; } + // Checksum is computed engine-side on receipt; 0 means "unset". + send(target.UTF8String, method.UTF8String, data.bytes, (int32_t)data.length, 0); } - (void)executeConsoleCommand:(NSString*)command { - FString CommandString = NSStringToFString(command); - - NSLog(@"[UnrealBridge] executeConsoleCommand: %@", command); - - if (GFlutterBridgeInstance) - { - GFlutterBridgeInstance->ExecuteConsoleCommand(CommandString); - } - else - { - NSLog(@"[UnrealBridge] Warning: FlutterBridge instance not set"); - } + ExecuteConsoleCommandFn exec = UNREAL_FN(ExecuteConsoleCommandFn, "UnrealBridge_ExecuteConsoleCommand"); + if (exec) exec(command.UTF8String); } - (void)loadLevel:(NSString*)levelName { - FString LevelNameString = NSStringToFString(levelName); - - NSLog(@"[UnrealBridge] loadLevel: %@", levelName); - - if (GFlutterBridgeInstance) - { - GFlutterBridgeInstance->LoadLevel(LevelNameString); - } - else - { - NSLog(@"[UnrealBridge] Warning: FlutterBridge instance not set"); - } + LoadLevelFn load = UNREAL_FN(LoadLevelFn, "UnrealBridge_LoadLevel"); + if (load) load(levelName.UTF8String); } - (void)applyQualitySettings:(NSDictionary*)settings { - NSLog(@"[UnrealBridge] applyQualitySettings called"); - - if (!GFlutterBridgeInstance) - { - NSLog(@"[UnrealBridge] Warning: FlutterBridge instance not set"); - return; - } - - // Parse settings - TMap SettingsMap = NSDictionaryToTMap(settings); - - // Extract quality settings - int32 QualityLevel = SettingsMap.Contains(TEXT("qualityLevel")) ? - FCString::Atoi(*SettingsMap[TEXT("qualityLevel")]) : -1; - int32 AntiAliasing = SettingsMap.Contains(TEXT("antiAliasingQuality")) ? - FCString::Atoi(*SettingsMap[TEXT("antiAliasingQuality")]) : -1; - int32 Shadow = SettingsMap.Contains(TEXT("shadowQuality")) ? - FCString::Atoi(*SettingsMap[TEXT("shadowQuality")]) : -1; - int32 PostProcess = SettingsMap.Contains(TEXT("postProcessQuality")) ? - FCString::Atoi(*SettingsMap[TEXT("postProcessQuality")]) : -1; - int32 Texture = SettingsMap.Contains(TEXT("textureQuality")) ? - FCString::Atoi(*SettingsMap[TEXT("textureQuality")]) : -1; - int32 Effects = SettingsMap.Contains(TEXT("effectsQuality")) ? - FCString::Atoi(*SettingsMap[TEXT("effectsQuality")]) : -1; - int32 Foliage = SettingsMap.Contains(TEXT("foliageQuality")) ? - FCString::Atoi(*SettingsMap[TEXT("foliageQuality")]) : -1; - int32 ViewDistance = SettingsMap.Contains(TEXT("viewDistanceQuality")) ? - FCString::Atoi(*SettingsMap[TEXT("viewDistanceQuality")]) : -1; - - // Apply settings - GFlutterBridgeInstance->ApplyQualitySettings( - QualityLevel, - AntiAliasing, - Shadow, - PostProcess, - Texture, - Effects, - Foliage, - ViewDistance - ); + ApplyQualitySettingsFn apply = UNREAL_FN(ApplyQualitySettingsFn, "UnrealBridge_ApplyQualitySettings"); + if (!apply) return; + + int32_t (^value)(NSString*) = ^int32_t(NSString* key) { + id v = settings[key]; + return v ? (int32_t)[v intValue] : -1; + }; + + apply( + value(@"qualityLevel"), + value(@"antiAliasingQuality"), + value(@"shadowQuality"), + value(@"postProcessQuality"), + value(@"textureQuality"), + value(@"effectsQuality"), + value(@"foliageQuality"), + value(@"viewDistanceQuality")); } - (NSDictionary*)getQualitySettings { - NSLog(@"[UnrealBridge] getQualitySettings called"); - - if (!GFlutterBridgeInstance) - { - NSLog(@"[UnrealBridge] Warning: FlutterBridge instance not set"); + GetQualitySettingsFn get = UNREAL_FN(GetQualitySettingsFn, "UnrealBridge_GetQualitySettings"); + if (!get) return @{}; + + int32_t values[kUnrealQualityValueCount]; + const int32_t written = get(values, kUnrealQualityValueCount); + if (written < kUnrealQualityValueCount) { + // The framework serves a cache refreshed on the game thread, so the + // very first call can land before it is populated. return @{}; } - // Get quality settings from Unreal - TMap Settings = GFlutterBridgeInstance->GetQualitySettings(); - - // Convert to NSDictionary - return TMapToNSDictionary(Settings); -} - -// ============================================================ -// MARK: - Callbacks from Unreal to Flutter -// ============================================================ - -- (void)notifyMessageWithTarget:(NSString*)target method:(NSString*)method data:(NSString*)data { - NSLog(@"[UnrealBridge] notifyMessage: Target=%@, Method=%@", target, method); - - if (GUnrealEngineController) - { - [GUnrealEngineController onMessageFromUnrealWithTarget:target method:method data:data]; - } - else - { - NSLog(@"[UnrealBridge] Warning: UnrealEngineController not set"); - } -} - -- (void)notifyLevelLoadedWithLevelName:(NSString*)levelName buildIndex:(NSInteger)buildIndex { - NSLog(@"[UnrealBridge] notifyLevelLoaded: %@", levelName); - - if (GUnrealEngineController) - { - [GUnrealEngineController onLevelLoadedWithLevelName:levelName buildIndex:(int)buildIndex]; - } - else - { - NSLog(@"[UnrealBridge] Warning: UnrealEngineController not set"); + NSArray* keys = UnrealQualityKeys(); + NSMutableDictionary* result = [NSMutableDictionary dictionaryWithCapacity:keys.count]; + for (NSUInteger i = 0; i < keys.count; i++) { + result[keys[i]] = @(values[i]); } + return result; } @end - -// ============================================================ -// MARK: - C++ Interface for Unreal Engine -// ============================================================ - -/** - * Send message to Flutter via Objective-C++ - * Called from AFlutterBridge::SendToFlutter() - */ -void FlutterBridge_SendToFlutter_Mac(const FString& Target, const FString& Method, const FString& Data) -{ - NSString* nsTarget = FStringToNSString(Target); - NSString* nsMethod = FStringToNSString(Method); - NSString* nsData = FStringToNSString(Data); - - dispatch_async(dispatch_get_main_queue(), ^{ - [[UnrealBridge shared] notifyMessageWithTarget:nsTarget method:nsMethod data:nsData]; - }); - - UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Mac] Message sent to Flutter: Target=%s, Method=%s"), - *Target, *Method); -} - -/** - * Notify Flutter that a level has been loaded - */ -void FlutterBridge_NotifyLevelLoaded_Mac(const FString& LevelName, int32 BuildIndex) -{ - NSString* nsLevelName = FStringToNSString(LevelName); - - dispatch_async(dispatch_get_main_queue(), ^{ - [[UnrealBridge shared] notifyLevelLoadedWithLevelName:nsLevelName buildIndex:BuildIndex]; - }); - - UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Mac] Level loaded notification sent: %s"), *LevelName); -} - -/** - * Set the FlutterBridge instance - * Called from AFlutterBridge::BeginPlay() - */ -void FlutterBridge_SetInstance_Mac(AFlutterBridge* Instance) -{ - GFlutterBridgeInstance = Instance; - UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Mac] FlutterBridge instance set")); -} - -#endif // PLATFORM_MAC diff --git a/engines/unreal/dart/macos/Classes/UnrealEngineController.swift b/engines/unreal/dart/macos/Classes/UnrealEngineController.swift index 2cbbcb4..b34d481 100644 --- a/engines/unreal/dart/macos/Classes/UnrealEngineController.swift +++ b/engines/unreal/dart/macos/Classes/UnrealEngineController.swift @@ -449,75 +449,14 @@ private class UnrealFramework { } // MARK: - Unreal Bridge Interface - -/** - * Bridge to Objective-C++ code that interfaces with Unreal C++ - * This class will be implemented in UnrealBridge.mm (Objective-C++) - */ -@objc public class UnrealBridge: NSObject { - - @objc public static let shared = UnrealBridge() - - private weak var controller: UnrealEngineController? - - private override init() { - super.init() - } - - // These methods will be implemented in UnrealBridge.mm (Objective-C++) - // They serve as the interface between Swift and Unreal C++ - - @objc public func create(config: [String: Any], controller: UnrealEngineController) -> Bool { - self.controller = controller - // Implementation in UnrealBridge.mm - return false // Placeholder - } - - @objc public func getView() -> NSView? { - // Implementation in UnrealBridge.mm - return nil // Placeholder - } - - @objc public func pause() { - // Implementation in UnrealBridge.mm - } - - @objc public func resume() { - // Implementation in UnrealBridge.mm - } - - @objc public func quit() { - // Implementation in UnrealBridge.mm - } - - @objc public func sendMessage(target: String, method: String, data: String) { - // Implementation in UnrealBridge.mm - } - - @objc public func executeConsoleCommand(_ command: String) { - // Implementation in UnrealBridge.mm - } - - @objc public func loadLevel(_ levelName: String) { - // Implementation in UnrealBridge.mm - } - - @objc public func applyQualitySettings(_ settings: [String: Any]) { - // Implementation in UnrealBridge.mm - } - - @objc public func getQualitySettings() -> [String: Any] { - // Implementation in UnrealBridge.mm - return [:] // Placeholder - } - - // Called from Unreal C++ to send messages to Flutter - @objc public func notifyMessage(target: String, method: String, data: String) { - controller?.onMessageFromUnreal(target: target, method: method, data: data) - } - - // Called from Unreal C++ when level is loaded - @objc public func notifyLevelLoaded(levelName: String, buildIndex: Int) { - controller?.onLevelLoaded(levelName: levelName, buildIndex: buildIndex) - } -} +// +// The UnrealBridge class is implemented in Objective-C++ (UnrealBridge.mm) and +// declared in UnrealBridge.h, which the pod exposes to Swift. It used to be +// duplicated here as a Swift placeholder whose methods all returned false or +// nil; two classes registered under the same Objective-C name, with the winner +// undefined, and the placeholder is the one that made createWithConfig always +// fail. Do not add it back. +// +// Callbacks from Unreal arrive on onMessageFromUnreal(target:method:data:) and +// onLevelLoaded(levelName:buildIndex:) above, dispatched by name from the +// bridge. diff --git a/engines/unreal/dart/macos/Tests/MockUnrealFramework.c b/engines/unreal/dart/macos/Tests/MockUnrealFramework.c new file mode 100644 index 0000000..797bdab --- /dev/null +++ b/engines/unreal/dart/macos/Tests/MockUnrealFramework.c @@ -0,0 +1,49 @@ +// Mock UnrealFramework: exports the C ABI so the pod's dlsym path can be +// exercised without an engine build. +#include +#include +#include + +typedef void (*UnrealMessageCallback)(const char*, const char*, const char*); +typedef void (*UnrealBinaryCallback)(const char*, const char*, const void*, int32_t, int32_t); + +static UnrealMessageCallback gMessage = 0; +static UnrealBinaryCallback gBinary = 0; + +char gLastTarget[128], gLastMethod[128], gLastData[256]; +int32_t gLastQuality[8]; +int gConsoleCalls = 0, gLevelCalls = 0, gPauseState = -1, gStopped = 0; + +void UnrealBridge_SetMessageCallback(UnrealMessageCallback cb) { gMessage = cb; } +void UnrealBridge_SetBinaryCallback(UnrealBinaryCallback cb) { gBinary = cb; } +void UnrealBridge_SendToUnreal(const char* t, const char* m, const char* d) { + snprintf(gLastTarget, sizeof gLastTarget, "%s", t ? t : ""); + snprintf(gLastMethod, sizeof gLastMethod, "%s", m ? m : ""); + snprintf(gLastData, sizeof gLastData, "%s", d ? d : ""); +} +void UnrealBridge_SendBinaryToUnreal(const char* t, const char* m, const void* d, int32_t n, int32_t c) { + (void)d; (void)c; + snprintf(gLastTarget, sizeof gLastTarget, "%s", t ? t : ""); + snprintf(gLastMethod, sizeof gLastMethod, "%s", m ? m : ""); + snprintf(gLastData, sizeof gLastData, "%d", n); +} +void UnrealBridge_ExecuteConsoleCommand(const char* c) { (void)c; gConsoleCalls++; } +void UnrealBridge_LoadLevel(const char* l) { (void)l; gLevelCalls++; } +void UnrealBridge_ApplyQualitySettings(int32_t a,int32_t b,int32_t c,int32_t d, + int32_t e,int32_t f,int32_t g,int32_t h) { + gLastQuality[0]=a; gLastQuality[1]=b; gLastQuality[2]=c; gLastQuality[3]=d; + gLastQuality[4]=e; gLastQuality[5]=f; gLastQuality[6]=g; gLastQuality[7]=h; +} +int32_t UnrealBridge_GetQualitySettings(int32_t* out, int32_t cap) { + if (!out || cap < 7) return 0; + for (int i = 0; i < 7; i++) out[i] = i + 1; + return 7; +} +void UnrealBridge_Pause(int32_t p) { gPauseState = p; } +void UnrealBridge_Stop(void) { gStopped = 1; } +int32_t UnrealBridge_IsReady(void) { return 1; } + +/// Drive a message from "Unreal" back into the pod. +void MockUnreal_FireMessage(const char* t, const char* m, const char* d) { + if (gMessage) gMessage(t, m, d); +} diff --git a/engines/unreal/dart/macos/Tests/SwiftApiCheck.swift b/engines/unreal/dart/macos/Tests/SwiftApiCheck.swift new file mode 100644 index 0000000..1316d20 --- /dev/null +++ b/engines/unreal/dart/macos/Tests/SwiftApiCheck.swift @@ -0,0 +1,19 @@ +import Cocoa + +// Mirrors every UnrealBridge call site in UnrealEngineController.swift. +// If NS_SWIFT_NAME in UnrealBridge.h drifts, this stops compiling. +func exerciseBridge(controller: NSObject) { + let ok: Bool = UnrealBridge.shared.create(config: ["a": 1], controller: controller) + _ = ok + let view: NSView? = UnrealBridge.shared.getView() + _ = view + UnrealBridge.shared.pause() + UnrealBridge.shared.resume() + UnrealBridge.shared.quit() + UnrealBridge.shared.sendMessage(target: "T", method: "M", data: "{}") + UnrealBridge.shared.executeConsoleCommand("stat fps") + UnrealBridge.shared.loadLevel("Arena") + UnrealBridge.shared.applyQualitySettings(["qualityLevel": 3]) + let q: [AnyHashable: Any] = UnrealBridge.shared.getQualitySettings() + _ = q +} diff --git a/engines/unreal/dart/macos/Tests/UnrealBridgeAbsentTests.mm b/engines/unreal/dart/macos/Tests/UnrealBridgeAbsentTests.mm new file mode 100644 index 0000000..4742f6b --- /dev/null +++ b/engines/unreal/dart/macos/Tests/UnrealBridgeAbsentTests.mm @@ -0,0 +1,34 @@ +// Every bridge call must be a safe no-op when UnrealFramework is not loaded. + +#import +#import +#import "UnrealBridge.h" + +int main(void) { + @autoreleasepool { + UnrealBridge* bridge = UnrealBridge.shared; + if (!bridge) { printf("FAIL: no shared instance\n"); return 1; } + printf("PASS: shared instance created\n"); + + BOOL created = [bridge createWithConfig:@{} controller:bridge]; + printf("%s: createWithConfig returned %s with framework absent\n", + created ? "FAIL" : "PASS", created ? "YES" : "NO"); + if (created) return 1; + + [bridge sendMessageWithTarget:@"T" method:@"M" data:@"{}"]; + [bridge sendBinaryWithTarget:@"T" method:@"M" data:[NSData data]]; + [bridge executeConsoleCommand:@"stat fps"]; + [bridge loadLevel:@"Main"]; + [bridge applyQualitySettings:@{@"qualityLevel": @3}]; + [bridge pause]; + [bridge resume]; + + NSDictionary* q = [bridge getQualitySettings]; + printf("%s: getQualitySettings returned %lu keys (expected 0)\n", + (q.count == 0) ? "PASS" : "FAIL", (unsigned long)q.count); + + [bridge quit]; + printf("PASS: all bridge calls survived with no framework loaded\n"); + return 0; + } +} diff --git a/engines/unreal/dart/macos/Tests/UnrealBridgeTests.mm b/engines/unreal/dart/macos/Tests/UnrealBridgeTests.mm new file mode 100644 index 0000000..142d2e9 --- /dev/null +++ b/engines/unreal/dart/macos/Tests/UnrealBridgeTests.mm @@ -0,0 +1,91 @@ +// Exercises UnrealBridge against a mock framework exporting the real C ABI. +// Runs natively on macOS, no simulator required. + +#import +#import +#import "UnrealBridge.h" + +extern "C" { +void MockUnreal_FireMessage(const char*, const char*, const char*); +extern char gLastTarget[128], gLastMethod[128], gLastData[256]; +extern int32_t gLastQuality[8]; +extern int gConsoleCalls, gLevelCalls, gPauseState, gStopped; +} + +static int gFailures = 0; +static void check(bool ok, const char* what) { + printf("%s: %s\n", ok ? "PASS" : "FAIL", what); + if (!ok) gFailures++; +} + +/// Stand-in for UnrealEngineController. +@interface FakeController : NSObject +@property (nonatomic, copy) NSString* gotTarget; +@property (nonatomic, copy) NSString* gotMethod; +@property (nonatomic, copy) NSString* gotData; +@property (nonatomic, copy) NSString* gotLevel; +@end +@implementation FakeController +- (void)onMessageFromUnrealWithTarget:(NSString*)t method:(NSString*)m data:(NSString*)d { + self.gotTarget = t; self.gotMethod = m; self.gotData = d; +} +- (void)onLevelLoadedWithLevelName:(NSString*)n buildIndex:(NSInteger)i { self.gotLevel = n; } +@end + +int main(void) { + @autoreleasepool { + UnrealBridge* bridge = UnrealBridge.shared; + check(bridge != nil, "shared instance exists"); + + FakeController* controller = [FakeController new]; + check([bridge createWithConfig:@{} controller:controller], + "createWithConfig succeeds when the framework is loaded"); + + [bridge sendMessageWithTarget:@"GameManager" method:@"startGame" data:@"{\"level\":1}"]; + check(strcmp(gLastTarget, "GameManager") == 0 && + strcmp(gLastMethod, "startGame") == 0 && + strcmp(gLastData, "{\"level\":1}") == 0, + "sendMessage reaches the framework with target, method and data intact"); + + NSData* payload = [@"binary-payload" dataUsingEncoding:NSUTF8StringEncoding]; + [bridge sendBinaryWithTarget:@"Assets" method:@"upload" data:payload]; + check(strcmp(gLastMethod, "upload") == 0 && atoi(gLastData) == (int)payload.length, + "sendBinary forwards the byte count"); + + [bridge executeConsoleCommand:@"stat fps"]; + check(gConsoleCalls == 1, "executeConsoleCommand reaches the framework"); + + [bridge loadLevel:@"Arena"]; + check(gLevelCalls == 1, "loadLevel reaches the framework"); + + [bridge applyQualitySettings:@{@"qualityLevel": @3, @"shadowQuality": @2}]; + check(gLastQuality[0] == 3 && gLastQuality[2] == 2 && gLastQuality[1] == -1, + "applyQualitySettings maps keys by position and defaults missing ones to -1"); + + NSDictionary* q = [bridge getQualitySettings]; + check([q[@"antiAliasing"] intValue] == 1 && [q[@"viewDistance"] intValue] == 7 && q.count == 7, + "getQualitySettings maps the value array back onto named keys in order"); + + [bridge pause]; + check(gPauseState == 1, "pause forwards 1"); + [bridge resume]; + check(gPauseState == 0, "resume forwards 0"); + + MockUnreal_FireMessage("GameManager", "onScore", "{\"score\":42}"); + MockUnreal_FireMessage("FlutterBridge", "onLevelLoaded", "Arena"); + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.3]]; + + check([controller.gotTarget isEqualToString:@"GameManager"] && + [controller.gotMethod isEqualToString:@"onScore"] && + [controller.gotData isEqualToString:@"{\"score\":42}"], + "a message from Unreal reaches the controller on the main thread"); + check([controller.gotLevel isEqualToString:@"Arena"], + "onLevelLoaded is rerouted to the controller's level callback"); + + [bridge quit]; + check(gStopped == 1, "quit stops the framework"); + + printf("\n%s (%d failures)\n", gFailures ? "FAILED" : "ALL PASSED", gFailures); + return gFailures ? 1 : 0; + } +} diff --git a/engines/unreal/dart/macos/Tests/run_bridge_tests.sh b/engines/unreal/dart/macos/Tests/run_bridge_tests.sh new file mode 100755 index 0000000..4ce5581 --- /dev/null +++ b/engines/unreal/dart/macos/Tests/run_bridge_tests.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# +# Exercise UnrealBridge.mm on macOS, with and without UnrealFramework present. +# +# There is no Unreal build in CI, so the "present" case runs against +# MockUnrealFramework.c, which exports the same C ABI the plugin's +# Public/UnrealBridge.h declares. That covers the pod side and the ABI contract +# in both directions. It does NOT cover the engine-side implementation in +# Private/FlutterBridge_Apple.cpp, which needs a real engine to build. +# +# Unlike the iOS equivalent these run natively, so no simulator is involved. + +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CLASSES="$HERE/../Classes" +BUILD="$(mktemp -d)" +trap 'rm -rf "$BUILD"' EXIT + +SDK="$(xcrun --sdk macosx --show-sdk-path)" + +compile_mm() { + xcrun --sdk macosx clang++ -fobjc-arc -x objective-c++ -std=c++17 \ + -Wall -Wextra -I"$CLASSES" -c "$1" -o "$2" +} + +echo "Building bridge..." +compile_mm "$CLASSES/UnrealBridge.mm" "$BUILD/UnrealBridge.o" + +echo "Building mock framework..." +xcrun --sdk macosx clang -dynamiclib -install_name @rpath/MockUnreal.dylib \ + "$HERE/MockUnrealFramework.c" -o "$BUILD/MockUnreal.dylib" + +echo +echo "=== Swift sees the bridge with the expected signatures ===" +xcrun swiftc -typecheck -sdk "$SDK" \ + -import-objc-header "$CLASSES/UnrealBridge.h" \ + "$HERE/SwiftApiCheck.swift" +echo "PASS: every UnrealBridge call site in UnrealEngineController type-checks" + +echo +echo "=== Framework absent ===" +compile_mm "$HERE/UnrealBridgeAbsentTests.mm" "$BUILD/absent.o" +xcrun --sdk macosx clang++ "$BUILD/UnrealBridge.o" "$BUILD/absent.o" \ + -framework Foundation -framework Cocoa -o "$BUILD/absent" +"$BUILD/absent" + +echo +echo "=== Framework present (mock) ===" +compile_mm "$HERE/UnrealBridgeTests.mm" "$BUILD/live.o" +xcrun --sdk macosx clang++ "$BUILD/UnrealBridge.o" "$BUILD/live.o" \ + "$BUILD/MockUnreal.dylib" \ + -framework Foundation -framework Cocoa -rpath "$BUILD" -o "$BUILD/live" +"$BUILD/live" diff --git a/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp b/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp new file mode 100644 index 0000000..14a085b --- /dev/null +++ b/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp @@ -0,0 +1,431 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include "FlutterBridge.h" + +#if PLATFORM_IOS || PLATFORM_MAC + +#include "UnrealBridge.h" +#include "Async/Async.h" + +#include + +// ============================================================ +// MARK: - Bridge State +// ============================================================ +// +// Shared by iOS and Mac. The two platforms differ only in the names +// AFlutterBridge dispatches to, so the implementation lives here once and the +// platform entry points at the bottom are shims. +// +// The host app links this framework and registers C callbacks. Nothing here +// touches Objective-C, UIKit or AppKit: the boundary is a flat C ABI so the app +// never needs Unreal headers, include paths or symbols of its own. +// +// Two threads are in play. Unreal calls into SendToFlutter from the game +// thread; the app calls the UnrealBridge_* entry points from its main thread. +// Callback pointers are therefore read and written atomically, and every call +// that touches UObjects is marshalled onto the game thread before it runs. + +static std::atomic GFlutterBridgeInstance{nullptr}; + +static std::atomic GMessageCallback{nullptr}; +static std::atomic GBinaryCallback{nullptr}; + +/// Cached quality settings, refreshed on the game thread. +/// +/// UnrealBridge_GetQualitySettings is synchronous and can be called from the +/// app's main thread, where blocking on the game thread risks deadlock against +/// a game thread already waiting on the main thread. So the getter serves this +/// cache and schedules a refresh for next time. The lock is only ever held for +/// a memcpy-sized copy, so neither thread stalls on it. +static FCriticalSection GQualityCacheLock; +static int32 GCachedQuality[UNREALBRIDGE_QUALITY_VALUE_COUNT] = {0}; +static bool GQualityCacheValid = false; + +/// Order must match the documented layout in UnrealBridge.h. +static const TCHAR* const GQualityKeys[UNREALBRIDGE_QUALITY_VALUE_COUNT] = { + TEXT("antiAliasing"), + TEXT("shadow"), + TEXT("postProcess"), + TEXT("texture"), + TEXT("effects"), + TEXT("foliage"), + TEXT("viewDistance") +}; + +// ============================================================ +// MARK: - Helpers +// ============================================================ + +/// Convert an incoming C string to FString, tolerating null. +static FString CStringToFString(const char* String) +{ + return String ? FString(UTF8_TO_TCHAR(String)) : FString(); +} + +/// Run work on the game thread, immediately if already there. +/// +/// Everything below reaches into UObjects, which is only legal on the game +/// thread. Calls arriving from the app's main thread get queued. +static void RunOnGameThread(TFunction Work) +{ + if (IsInGameThread()) + { + Work(); + } + else + { + AsyncTask(ENamedThreads::GameThread, MoveTemp(Work)); + } +} + +/// Refresh the quality cache. Game thread only. +static void RefreshQualityCache() +{ + check(IsInGameThread()); + + AFlutterBridge* Bridge = GFlutterBridgeInstance.load(std::memory_order_acquire); + if (!Bridge) + { + return; + } + + const TMap Settings = Bridge->GetQualitySettings(); + + FScopeLock Lock(&GQualityCacheLock); + for (int32 Index = 0; Index < UNREALBRIDGE_QUALITY_VALUE_COUNT; ++Index) + { + const int32* Value = Settings.Find(GQualityKeys[Index]); + GCachedQuality[Index] = Value ? *Value : -1; + } + + GQualityCacheValid = true; +} + +// ============================================================ +// MARK: - Unreal to Flutter +// ============================================================ + +/** + * Send a message to Flutter. + * Called from AFlutterBridge::SendToFlutter() on the game thread. + * + * The callback receives pointers into a temporary UTF-8 conversion, so the + * host must copy anything it intends to keep. This is documented on the + * typedef in UnrealBridge.h. + */ +static void SendToFlutter_Apple(const FString& Target, const FString& Method, const FString& Data) +{ + const UnrealMessageCallback Callback = GMessageCallback.load(std::memory_order_acquire); + if (!Callback) + { + UE_LOG(LogTemp, Verbose, + TEXT("[FlutterBridge_Apple] Dropping message, no callback registered: Target=%s, Method=%s"), + *Target, *Method); + return; + } + + const FTCHARToUTF8 TargetUtf8(*Target); + const FTCHARToUTF8 MethodUtf8(*Method); + const FTCHARToUTF8 DataUtf8(*Data); + + Callback(TargetUtf8.Get(), MethodUtf8.Get(), DataUtf8.Get()); +} + +/** + * Send binary data to Flutter. + * Called from AFlutterBridge::SendBinaryToFlutter() on the game thread. + */ +static void SendBinaryToFlutter_Apple(const FString& Target, const FString& Method, const TArray& Data, int32 Checksum) +{ + const UnrealBinaryCallback Callback = GBinaryCallback.load(std::memory_order_acquire); + if (!Callback) + { + UE_LOG(LogTemp, Verbose, + TEXT("[FlutterBridge_Apple] Dropping binary payload, no callback registered: Target=%s, Method=%s, Size=%d"), + *Target, *Method, Data.Num()); + return; + } + + const FTCHARToUTF8 TargetUtf8(*Target); + const FTCHARToUTF8 MethodUtf8(*Method); + + Callback(TargetUtf8.Get(), MethodUtf8.Get(), Data.GetData(), Data.Num(), Checksum); +} + +// ============================================================ +// MARK: - Instance Registration +// ============================================================ + +/** + * Set the FlutterBridge instance. + * Called from AFlutterBridge::BeginPlay() on the game thread. + */ +static void SetInstance_Apple(AFlutterBridge* Instance) +{ + GFlutterBridgeInstance.store(Instance, std::memory_order_release); + + if (Instance) + { + RefreshQualityCache(); + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Apple] FlutterBridge instance set")); + } + else + { + FScopeLock Lock(&GQualityCacheLock); + GQualityCacheValid = false; + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Apple] FlutterBridge instance cleared")); + } +} + +// ============================================================ +// MARK: - Platform Entry Points +// ============================================================ +// +// AFlutterBridge dispatches to a differently named function per platform. iOS +// and Mac share everything above, so these are shims. UnrealBuildTool compiles +// exactly one branch. + +#if PLATFORM_IOS + +void FlutterBridge_SendToFlutter_iOS(const FString& Target, const FString& Method, const FString& Data) +{ + SendToFlutter_Apple(Target, Method, Data); +} + +void FlutterBridge_SendBinaryToFlutter_iOS(const FString& Target, const FString& Method, const TArray& Data, int32 Checksum) +{ + SendBinaryToFlutter_Apple(Target, Method, Data, Checksum); +} + +void FlutterBridge_SetInstance_iOS(AFlutterBridge* Instance) +{ + SetInstance_Apple(Instance); +} + +AFlutterBridge* FlutterBridge_GetInstance_iOS() +{ + return GFlutterBridgeInstance.load(std::memory_order_acquire); +} + +#elif PLATFORM_MAC + +void FlutterBridge_SendToFlutter_Mac(const FString& Target, const FString& Method, const FString& Data) +{ + SendToFlutter_Apple(Target, Method, Data); +} + +void FlutterBridge_SendBinaryToFlutter_Mac(const FString& Target, const FString& Method, const TArray& Data, int32 Checksum) +{ + SendBinaryToFlutter_Apple(Target, Method, Data, Checksum); +} + +void FlutterBridge_SetInstance_Mac(AFlutterBridge* Instance) +{ + SetInstance_Apple(Instance); +} + +AFlutterBridge* FlutterBridge_GetInstance_Mac() +{ + return GFlutterBridgeInstance.load(std::memory_order_acquire); +} + +#endif + +// ============================================================ +// MARK: - C ABI (called by the host app) +// ============================================================ + +extern "C" { + +void UnrealBridge_SetMessageCallback(UnrealMessageCallback Callback) +{ + GMessageCallback.store(Callback, std::memory_order_release); + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Apple] Message callback %s"), + Callback ? TEXT("registered") : TEXT("cleared")); +} + +void UnrealBridge_SetBinaryCallback(UnrealBinaryCallback Callback) +{ + GBinaryCallback.store(Callback, std::memory_order_release); + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Apple] Binary callback %s"), + Callback ? TEXT("registered") : TEXT("cleared")); +} + +void UnrealBridge_SendToUnreal(const char* Target, const char* Method, const char* Data) +{ + const FString TargetString = CStringToFString(Target); + const FString MethodString = CStringToFString(Method); + const FString DataString = CStringToFString(Data); + + RunOnGameThread([TargetString, MethodString, DataString]() + { + if (AFlutterBridge* Bridge = GFlutterBridgeInstance.load(std::memory_order_acquire)) + { + Bridge->ReceiveFromFlutter(TargetString, MethodString, DataString); + } + else + { + UE_LOG(LogTemp, Warning, + TEXT("[FlutterBridge_Apple] Dropping message from Flutter, no bridge actor: Target=%s, Method=%s"), + *TargetString, *MethodString); + } + }); +} + +void UnrealBridge_SendBinaryToUnreal(const char* Target, const char* Method, const void* Data, int32_t Length, int32_t Checksum) +{ + const FString TargetString = CStringToFString(Target); + const FString MethodString = CStringToFString(Method); + + // Copy now. The caller owns its buffer and is free to release it as soon as + // this returns, but the work below runs later on the game thread. + TArray Payload; + if (Data && Length > 0) + { + Payload.Append(static_cast(Data), Length); + } + + RunOnGameThread([TargetString, MethodString, Payload = MoveTemp(Payload), Checksum]() + { + if (AFlutterBridge* Bridge = GFlutterBridgeInstance.load(std::memory_order_acquire)) + { + Bridge->ReceiveBinaryFromFlutter(TargetString, MethodString, Payload, Checksum); + } + else + { + UE_LOG(LogTemp, Warning, + TEXT("[FlutterBridge_Apple] Dropping binary from Flutter, no bridge actor: Target=%s, Method=%s, Size=%d"), + *TargetString, *MethodString, Payload.Num()); + } + }); +} + +void UnrealBridge_ExecuteConsoleCommand(const char* Command) +{ + const FString CommandString = CStringToFString(Command); + + RunOnGameThread([CommandString]() + { + if (AFlutterBridge* Bridge = GFlutterBridgeInstance.load(std::memory_order_acquire)) + { + Bridge->ExecuteConsoleCommand(CommandString); + } + }); +} + +void UnrealBridge_LoadLevel(const char* LevelName) +{ + const FString LevelString = CStringToFString(LevelName); + + RunOnGameThread([LevelString]() + { + if (AFlutterBridge* Bridge = GFlutterBridgeInstance.load(std::memory_order_acquire)) + { + Bridge->LoadLevel(LevelString); + } + }); +} + +void UnrealBridge_ApplyQualitySettings( + int32_t QualityLevel, + int32_t AntiAliasing, + int32_t Shadow, + int32_t PostProcess, + int32_t Texture, + int32_t Effects, + int32_t Foliage, + int32_t ViewDistance) +{ + RunOnGameThread([=]() + { + AFlutterBridge* Bridge = GFlutterBridgeInstance.load(std::memory_order_acquire); + if (!Bridge) + { + return; + } + + Bridge->ApplyQualitySettings( + QualityLevel, AntiAliasing, Shadow, PostProcess, + Texture, Effects, Foliage, ViewDistance); + + RefreshQualityCache(); + }); +} + +int32_t UnrealBridge_GetQualitySettings(int32_t* OutValues, int32_t Capacity) +{ + // Schedule a refresh regardless, so a later call sees current values. + RunOnGameThread([]() + { + RefreshQualityCache(); + }); + + if (!OutValues || Capacity < UNREALBRIDGE_QUALITY_VALUE_COUNT) + { + return 0; + } + + FScopeLock Lock(&GQualityCacheLock); + if (!GQualityCacheValid) + { + return 0; + } + + for (int32 Index = 0; Index < UNREALBRIDGE_QUALITY_VALUE_COUNT; ++Index) + { + OutValues[Index] = GCachedQuality[Index]; + } + + return UNREALBRIDGE_QUALITY_VALUE_COUNT; +} + +void UnrealBridge_Pause(int32_t Paused) +{ + const bool bPaused = Paused != 0; + + RunOnGameThread([bPaused]() + { + AFlutterBridge* Bridge = GFlutterBridgeInstance.load(std::memory_order_acquire); + if (!Bridge) + { + return; + } + + if (bPaused) + { + Bridge->OnEnginePause(); + } + else + { + Bridge->OnEngineResume(); + } + }); +} + +void UnrealBridge_Stop(void) +{ + // Clear callbacks first. The host is going away, and the quit path below + // can still produce messages we would otherwise hand to a dead callback. + GMessageCallback.store(nullptr, std::memory_order_release); + GBinaryCallback.store(nullptr, std::memory_order_release); + + RunOnGameThread([]() + { + if (AFlutterBridge* Bridge = GFlutterBridgeInstance.load(std::memory_order_acquire)) + { + Bridge->OnEngineQuit(); + } + }); + + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Apple] Bridge stopped")); +} + +int32_t UnrealBridge_IsReady(void) +{ + return GFlutterBridgeInstance.load(std::memory_order_acquire) != nullptr ? 1 : 0; +} + +} // extern "C" + +#endif // PLATFORM_IOS || PLATFORM_MAC diff --git a/engines/unreal/plugin/Source/FlutterPlugin/Private/IOS/FlutterBridge_IOS.cpp b/engines/unreal/plugin/Source/FlutterPlugin/Private/IOS/FlutterBridge_IOS.cpp deleted file mode 100644 index 4ac6aac..0000000 --- a/engines/unreal/plugin/Source/FlutterPlugin/Private/IOS/FlutterBridge_IOS.cpp +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright Epic Games, Inc. All Rights Reserved. - -#include "FlutterBridge.h" - -#if PLATFORM_IOS - -// Reference to FlutterBridge instance -static AFlutterBridge* GFlutterBridgeInstance = nullptr; - -// ============================================================ -// MARK: - Platform Bridge Functions -// ============================================================ - -/** - * Send message to Flutter via iOS - * Called from AFlutterBridge::SendToFlutter() - * - * Note: In a real implementation, this would communicate with the Flutter app - * via method channels or a custom IPC mechanism. - */ -void FlutterBridge_SendToFlutter_iOS(const FString& Target, const FString& Method, const FString& Data) -{ - UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_iOS] SendToFlutter: Target=%s, Method=%s, Data=%s"), - *Target, *Method, *Data); - - // TODO: Implement actual communication with Flutter - // This would typically use method channels via the Flutter engine -} - -/** - * Send binary data to Flutter via iOS - * Called from AFlutterBridge::SendBinaryToFlutter() - */ -void FlutterBridge_SendBinaryToFlutter_iOS(const FString& Target, const FString& Method, const TArray& Data, int32 Checksum) -{ - UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_iOS] SendBinaryToFlutter: Target=%s, Method=%s, Size=%d, Checksum=%d"), - *Target, *Method, Data.Num(), Checksum); - - // TODO: Implement actual binary communication with Flutter - // This would typically use method channels via the Flutter engine -} - -/** - * Set the FlutterBridge instance - * Called from AFlutterBridge::BeginPlay() - */ -void FlutterBridge_SetInstance_iOS(AFlutterBridge* Instance) -{ - GFlutterBridgeInstance = Instance; - UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_iOS] FlutterBridge instance set")); -} - -/** - * Get the FlutterBridge instance - */ -AFlutterBridge* FlutterBridge_GetInstance_iOS() -{ - return GFlutterBridgeInstance; -} - -/** - * Receive a message from Flutter - * This should be called from the Flutter side when a message needs to be sent to Unreal - */ -void FlutterBridge_ReceiveFromFlutter_iOS(const FString& Target, const FString& Method, const FString& Data) -{ - if (GFlutterBridgeInstance) - { - GFlutterBridgeInstance->ReceiveFromFlutter(Target, Method, Data); - } - else - { - UE_LOG(LogTemp, Warning, TEXT("[FlutterBridge_iOS] Cannot receive from Flutter: Bridge instance not set")); - } -} - -#endif // PLATFORM_IOS diff --git a/engines/unreal/plugin/Source/FlutterPlugin/Private/Mac/FlutterBridge_Mac.cpp b/engines/unreal/plugin/Source/FlutterPlugin/Private/Mac/FlutterBridge_Mac.cpp deleted file mode 100644 index 26d78b5..0000000 --- a/engines/unreal/plugin/Source/FlutterPlugin/Private/Mac/FlutterBridge_Mac.cpp +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright Epic Games, Inc. All Rights Reserved. - -#include "FlutterBridge.h" - -#if PLATFORM_MAC - -// Reference to FlutterBridge instance -static AFlutterBridge* GFlutterBridgeInstance = nullptr; - -// ============================================================ -// MARK: - Platform Bridge Functions -// ============================================================ - -/** - * Send message to Flutter via macOS - * Called from AFlutterBridge::SendToFlutter() - * - * Note: In a real implementation, this would communicate with the Flutter app - * via method channels or a custom IPC mechanism. - */ -void FlutterBridge_SendToFlutter_Mac(const FString& Target, const FString& Method, const FString& Data) -{ - UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Mac] SendToFlutter: Target=%s, Method=%s, Data=%s"), - *Target, *Method, *Data); - - // TODO: Implement actual communication with Flutter - // This would typically use NSNotificationCenter, method channels, or a custom bridge -} - -/** - * Send binary data to Flutter via macOS - * Called from AFlutterBridge::SendBinaryToFlutter() - */ -void FlutterBridge_SendBinaryToFlutter_Mac(const FString& Target, const FString& Method, const TArray& Data, int32 Checksum) -{ - UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Mac] SendBinaryToFlutter: Target=%s, Method=%s, Size=%d, Checksum=%d"), - *Target, *Method, Data.Num(), Checksum); - - // TODO: Implement actual binary communication with Flutter - // This would typically use method channels via the Flutter engine -} - -/** - * Set the FlutterBridge instance - * Called from AFlutterBridge::BeginPlay() - */ -void FlutterBridge_SetInstance_Mac(AFlutterBridge* Instance) -{ - GFlutterBridgeInstance = Instance; - UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Mac] FlutterBridge instance set")); -} - -/** - * Get the FlutterBridge instance - */ -AFlutterBridge* FlutterBridge_GetInstance_Mac() -{ - return GFlutterBridgeInstance; -} - -/** - * Receive a message from Flutter - * This should be called from the Flutter side when a message needs to be sent to Unreal - */ -void FlutterBridge_ReceiveFromFlutter_Mac(const FString& Target, const FString& Method, const FString& Data) -{ - if (GFlutterBridgeInstance) - { - GFlutterBridgeInstance->ReceiveFromFlutter(Target, Method, Data); - } - else - { - UE_LOG(LogTemp, Warning, TEXT("[FlutterBridge_Mac] Cannot receive from Flutter: Bridge instance not set")); - } -} - -#endif // PLATFORM_MAC diff --git a/engines/unreal/plugin/Source/FlutterPlugin/Public/UnrealBridge.h b/engines/unreal/plugin/Source/FlutterPlugin/Public/UnrealBridge.h new file mode 100644 index 0000000..7e41beb --- /dev/null +++ b/engines/unreal/plugin/Source/FlutterPlugin/Public/UnrealBridge.h @@ -0,0 +1,120 @@ +// +// UnrealBridge.h +// FlutterPlugin +// +// Flat C ABI across the UnrealFramework boundary. +// +// This header is deliberately free of Unreal types. The Flutter side compiles +// it inside the host app, which must not need CoreMinimal.h, UBT include paths +// or any engine symbols. Everything crossing the boundary is a C primitive. +// +// Direction of travel: +// Flutter -> Unreal UnrealBridge_SendToUnreal and friends +// Unreal -> Flutter callbacks registered with UnrealBridge_Set*Callback +// +// Threading: every UnrealBridge_* entry point is safe to call from any thread +// and hops to the game thread internally. Callbacks fire on the GAME thread, +// so the Flutter side must marshal to the main thread before touching UIKit. +// + +#ifndef UnrealBridge_h +#define UnrealBridge_h + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/// Exported from UnrealFramework so the host app can link against it. +#define UNREALBRIDGE_API __attribute__((visibility("default"))) + +// ============================================================ +// MARK: - Unreal to Flutter +// ============================================================ + +/// A string message from Unreal. Pointers are valid only for the duration of +/// the call; copy anything you need to keep. +typedef void (*UnrealMessageCallback)(const char* target, + const char* method, + const char* data); + +/// Binary payload from Unreal. `data` is valid only for the duration of the +/// call. `checksum` is the CRC32 Unreal computed over the payload. +typedef void (*UnrealBinaryCallback)(const char* target, + const char* method, + const void* data, + int32_t length, + int32_t checksum); + +/// Register callbacks. Pass NULL to unregister. Registering replaces any +/// previous callback rather than chaining. +/// +/// There is no separate level-loaded callback: Unreal reports level loads +/// through the message callback with target "FlutterBridge" and method +/// "onLevelLoaded", carrying the level name as data. +UNREALBRIDGE_API void UnrealBridge_SetMessageCallback(UnrealMessageCallback callback); +UNREALBRIDGE_API void UnrealBridge_SetBinaryCallback(UnrealBinaryCallback callback); + +// ============================================================ +// MARK: - Flutter to Unreal +// ============================================================ + +UNREALBRIDGE_API void UnrealBridge_SendToUnreal(const char* target, + const char* method, + const char* data); + +UNREALBRIDGE_API void UnrealBridge_SendBinaryToUnreal(const char* target, + const char* method, + const void* data, + int32_t length, + int32_t checksum); + +UNREALBRIDGE_API void UnrealBridge_ExecuteConsoleCommand(const char* command); + +UNREALBRIDGE_API void UnrealBridge_LoadLevel(const char* levelName); + +/// Quality levels are 0 to 4 (Low, Medium, High, Epic, Cinematic). Pass -1 for +/// any value that should be left alone. +UNREALBRIDGE_API void UnrealBridge_ApplyQualitySettings(int32_t qualityLevel, + int32_t antiAliasing, + int32_t shadow, + int32_t postProcess, + int32_t texture, + int32_t effects, + int32_t foliage, + int32_t viewDistance); + +/// Read the current quality settings into a caller-owned array, in this order: +/// +/// 0 antiAliasing, 1 shadow, 2 postProcess, 3 texture, +/// 4 effects, 5 foliage, 6 viewDistance +/// +/// Note this is the Apply order minus the leading overall quality level, which +/// Unreal exposes no getter for. Returns the number of values written, or 0 if +/// the bridge is not ready or `capacity` is too small. +UNREALBRIDGE_API int32_t UnrealBridge_GetQualitySettings(int32_t* outValues, + int32_t capacity); + +/// Number of values UnrealBridge_GetQualitySettings writes when it succeeds. +#define UNREALBRIDGE_QUALITY_VALUE_COUNT 7 + +// ============================================================ +// MARK: - Lifecycle +// ============================================================ + +UNREALBRIDGE_API void UnrealBridge_Pause(int32_t paused); + +/// Tear the bridge down. Clears the registered callbacks and tells Unreal the +/// host is going away. +UNREALBRIDGE_API void UnrealBridge_Stop(void); + +/// Whether an AFlutterBridge actor has registered itself. Everything above is +/// safe to call when this returns 0, it just does nothing. +UNREALBRIDGE_API int32_t UnrealBridge_IsReady(void); + +#ifdef __cplusplus +} +#endif + +#endif /* UnrealBridge_h */ From bed10f636bfbadc5712264da8e3d8e8758a263ef Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Thu, 3 Sep 2026 21:13:58 -0500 Subject: [PATCH 03/25] Build Unreal as a linkable dylib on Apple, and fix what that surfaced The blocker was never that Unreal cannot produce a linkable image. It was that nothing had ever asked it to. UE 5.8 links a dynamic library on both Apple platforms. iOS drives it from an ini key that defaults to off, and UEBuildIOS copies that into bShouldCompileAsDLL for you: [/Script/IOSRuntimeSettings.IOSRuntimeSettings] bBuildAsFramework=True Mac has no equivalent switch, so the target asks directly. Note the escape hatch: a unique build environment is rejected outright by a launcher-installed engine, so use bOverrideBuildEnvironment instead. Building the example project for Mac produces Binaries/Mac/libGameFrameworkProject.dylib, filetype DYLIB, exporting all eleven UnrealBridge_* entry points. Loading it with dlopen and driving it through the pod's bridge runs every call cleanly. Getting there meant fixing code that had never been compiled: - RotatingCube overrode GetFlutterTargetName and OnFlutterMessage_Implementation, neither of which the base class declares. It is GetFlutterTargetName_Implementation and HandleFlutterMessage_Implementation. It also assigned a FlutterTargetName member that does not exist, and was missing includes for UStaticMeshComponent and UMaterialInstanceDynamic. - FlutterGameMode declared a UFUNCTION GetLevel, which collides with AActor's, and used UWorld without including Engine/World.h. - FlutterAssetManager called a NewObject overload that does not exist. - FlutterBlueprintLibrary assumed FJsonObject::Values is keyed by FString. In UE 5.8 it is a shared string type and needs converting. The bridge itself needed one fix, found by loading the real dylib. Every entry point dispatched through the task graph before checking anything, and a host can call in before the engine has initialised, where IsInGameThread and AsyncTask read globals that do not exist yet. They now return early when no AFlutterBridge has registered, which is what the callee did anyway. The Unreal gitignore rules only matched unreal/Binaries, not unreal//Binaries, so a real project layout left 460MB of build output untracked but visible. --- .gitignore | 7 + .../Private/FlutterAssetManager.cpp | 3 +- .../Private/FlutterBlueprintLibrary.cpp | 4 +- .../Private/FlutterBridge_Apple.cpp | 10 + example/.game.yml | 28 + example/unreal/demo/Config/DefaultEngine.ini | 29 + example/unreal/demo/Config/DefaultGame.ini | 9 + example/unreal/demo/Config/DefaultInput.ini | 3 + .../unreal/demo/GameFrameworkProject.uproject | 19 + .../FlutterPlugin/FlutterPlugin.uplugin | 31 + .../FlutterPlugin/FlutterPlugin.Build.cs | 90 ++ .../FlutterPlugin_Android_UPL.xml | 370 +++++++++ .../Private/Android/FlutterBridge_Android.cpp | 781 ++++++++++++++++++ .../Private/FlutterAssetManager.cpp | 486 +++++++++++ .../Private/FlutterBlueprintLibrary.cpp | 238 ++++++ .../FlutterPlugin/Private/FlutterBridge.cpp | 680 +++++++++++++++ .../Private/FlutterBridge_Apple.cpp | 441 ++++++++++ .../Private/FlutterMessageRouter.cpp | 395 +++++++++ .../FlutterPlugin/Private/FlutterPlugin.cpp | 21 + .../Public/FlutterAssetManager.h | 280 +++++++ .../Public/FlutterBlueprintLibrary.h | 195 +++++ .../FlutterPlugin/Public/FlutterBridge.h | 379 +++++++++ .../Public/FlutterMessageRouter.h | 303 +++++++ .../FlutterPlugin/Public/FlutterPlugin.h | 14 + .../FlutterPlugin/Public/UnrealBridge.h | 120 +++ .../Source/GameFrameworkProject.Target.cs | 30 + .../GameFrameworkProject/FlutterActor.cpp | 181 ++++ .../GameFrameworkProject/FlutterActor.h | 183 ++++ .../GameFrameworkProject/FlutterGameMode.cpp | 299 +++++++ .../GameFrameworkProject/FlutterGameMode.h | 264 ++++++ .../GameFrameworkProject.Build.cs | 19 + .../GameFrameworkProject.cpp | 6 + .../GameFrameworkProject.h | 5 + .../Source/GameFrameworkProject/README.md | 222 +++++ .../ROTATING_CUBE_DEMO.md | 482 +++++++++++ .../GameFrameworkProject/RotatingCube.cpp | 311 +++++++ .../GameFrameworkProject/RotatingCube.h | 139 ++++ .../GameFrameworkProjectEditor.Target.cs | 13 + templates/unreal/Scripts/FlutterGameMode.cpp | 1 + templates/unreal/Scripts/FlutterGameMode.h | 2 +- templates/unreal/Scripts/RotatingCube.cpp | 5 +- templates/unreal/Scripts/RotatingCube.h | 6 +- 42 files changed, 7095 insertions(+), 9 deletions(-) create mode 100644 example/unreal/demo/Config/DefaultEngine.ini create mode 100644 example/unreal/demo/Config/DefaultGame.ini create mode 100644 example/unreal/demo/Config/DefaultInput.ini create mode 100644 example/unreal/demo/GameFrameworkProject.uproject create mode 100644 example/unreal/demo/Plugins/FlutterPlugin/FlutterPlugin.uplugin create mode 100644 example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/FlutterPlugin.Build.cs create mode 100644 example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/FlutterPlugin_Android_UPL.xml create mode 100644 example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/Android/FlutterBridge_Android.cpp create mode 100644 example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterAssetManager.cpp create mode 100644 example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBlueprintLibrary.cpp create mode 100644 example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge.cpp create mode 100644 example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp create mode 100644 example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterMessageRouter.cpp create mode 100644 example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterPlugin.cpp create mode 100644 example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/FlutterAssetManager.h create mode 100644 example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/FlutterBlueprintLibrary.h create mode 100644 example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/FlutterBridge.h create mode 100644 example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/FlutterMessageRouter.h create mode 100644 example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/FlutterPlugin.h create mode 100644 example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/UnrealBridge.h create mode 100644 example/unreal/demo/Source/GameFrameworkProject.Target.cs create mode 100644 example/unreal/demo/Source/GameFrameworkProject/FlutterActor.cpp create mode 100644 example/unreal/demo/Source/GameFrameworkProject/FlutterActor.h create mode 100644 example/unreal/demo/Source/GameFrameworkProject/FlutterGameMode.cpp create mode 100644 example/unreal/demo/Source/GameFrameworkProject/FlutterGameMode.h create mode 100644 example/unreal/demo/Source/GameFrameworkProject/GameFrameworkProject.Build.cs create mode 100644 example/unreal/demo/Source/GameFrameworkProject/GameFrameworkProject.cpp create mode 100644 example/unreal/demo/Source/GameFrameworkProject/GameFrameworkProject.h create mode 100644 example/unreal/demo/Source/GameFrameworkProject/README.md create mode 100644 example/unreal/demo/Source/GameFrameworkProject/ROTATING_CUBE_DEMO.md create mode 100644 example/unreal/demo/Source/GameFrameworkProject/RotatingCube.cpp create mode 100644 example/unreal/demo/Source/GameFrameworkProject/RotatingCube.h create mode 100644 example/unreal/demo/Source/GameFrameworkProjectEditor.Target.cs diff --git a/.gitignore b/.gitignore index 72acdee..b130159 100644 --- a/.gitignore +++ b/.gitignore @@ -177,6 +177,13 @@ build/ # ============================================================================ # Unreal Engine Integration # ============================================================================ +# These need the extra ** because an Unreal project usually sits one level +# down, as unreal//, and plugins nest their own build output again. +**/unreal/**/Binaries/ +**/unreal/**/Build/ +**/unreal/**/Intermediate/ +**/unreal/**/Saved/ +**/unreal/**/DerivedDataCache/ **/unreal/Binaries/ **/unreal/Build/ **/unreal/Intermediate/ diff --git a/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterAssetManager.cpp b/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterAssetManager.cpp index ac7852f..93e0052 100644 --- a/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterAssetManager.cpp +++ b/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterAssetManager.cpp @@ -18,7 +18,8 @@ UFlutterAssetManager* UFlutterAssetManager::Get(UObject* WorldContextObject) { if (!Instance) { - Instance = NewObject(GetTransientPackage(), NAME_None, RF_MarkAsRootSet); + Instance = NewObject(); + Instance->AddToRoot(); } return Instance; } diff --git a/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBlueprintLibrary.cpp b/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBlueprintLibrary.cpp index fb2cade..b25c647 100644 --- a/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBlueprintLibrary.cpp +++ b/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBlueprintLibrary.cpp @@ -212,12 +212,12 @@ TMap UFlutterBlueprintLibrary::JsonStringToMap(const FString& FString Value; if (Pair.Value->TryGetString(Value)) { - Result.Add(Pair.Key, Value); + Result.Add(FString(Pair.Key), Value); } else { // Convert non-string values to string representation - Result.Add(Pair.Key, Pair.Value->AsString()); + Result.Add(FString(Pair.Key), Pair.Value->AsString()); } } } diff --git a/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp b/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp index 14a085b..f582d95 100644 --- a/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp +++ b/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp @@ -69,6 +69,16 @@ static FString CStringToFString(const char* String) /// thread. Calls arriving from the app's main thread get queued. static void RunOnGameThread(TFunction Work) { + // Nothing to deliver to, and more importantly nothing safe to ask. Until an + // AFlutterBridge has registered, the engine may not be initialised at all: + // the host can load this library and call in before PreInit has run, and + // IsInGameThread and the task graph both read globals that do not exist + // yet. Dropping the call here is what the callee would have done anyway. + if (GFlutterBridgeInstance.load(std::memory_order_acquire) == nullptr) + { + return; + } + if (IsInGameThread()) { Work(); diff --git a/example/.game.yml b/example/.game.yml index 55953d0..d7caf98 100644 --- a/example/.game.yml +++ b/example/.game.yml @@ -6,6 +6,34 @@ version: 1.0.0 # Engine configurations engines: + unreal: + project_path: unreal/demo + export_path: unreal/demo_exports + + export_settings: + development: false + build_configuration: Shipping + + platforms: + android: + enabled: true + target_path: android/unrealLibrary + + ios: + enabled: true + target_path: ios/UnrealFramework.framework + + macos: + enabled: false + target_path: macos/UnrealFramework.framework + + windows: + enabled: false + target_path: windows/unreal_build + + linux: + enabled: false + target_path: linux/unreal_build unity: # Path to your Unity project project_path: unity/demo/Demo diff --git a/example/unreal/demo/Config/DefaultEngine.ini b/example/unreal/demo/Config/DefaultEngine.ini new file mode 100644 index 0000000..f4438f7 --- /dev/null +++ b/example/unreal/demo/Config/DefaultEngine.ini @@ -0,0 +1,29 @@ +[/Script/Engine.Engine] ++ActiveGameNameRedirects=(OldGameName="TP_Blank",NewGameName="/Script/GameFrameworkProject") + +[/Script/EngineSettings.GameMapsSettings] +GameDefaultMap= +EditorStartupMap= + +[/Script/AndroidRuntimeSettings.AndroidRuntimeSettings] +PackageName=com.gameframework.project +StoreVersion=1 +StoreVersionOffsetArm64=0 +StoreVersionOffsetX8664=0 +bBuildForES31=False +bSupportsVulkan=True +MinSDKVersion=24 +TargetSDKVersion=33 +bPackageDataInsideApk=True + +[/Script/IOSRuntimeSettings.IOSRuntimeSettings] +; Build the game as UnrealGame.framework rather than a standalone .app, so a +; Flutter host can link it. UEBuildIOS.ResetTarget copies this into +; bShouldCompileAsDLL, which makes IOSToolChain link with -dynamiclib and an +; @executable_path/Frameworks install name, and defines BUILD_EMBEDDED_APP=1. +bBuildAsFramework=True +BundleIdentifier=com.gameframework.project +bAutomaticSigning=True +IOSTeamID= +bShipForBitcode=False +MinimumiOSVersion=15.0 diff --git a/example/unreal/demo/Config/DefaultGame.ini b/example/unreal/demo/Config/DefaultGame.ini new file mode 100644 index 0000000..358d5e9 --- /dev/null +++ b/example/unreal/demo/Config/DefaultGame.ini @@ -0,0 +1,9 @@ +[/Script/EngineSettings.GeneralProjectSettings] +ProjectID=(A=0,B=0,C=0,D=0) +ProjectName=Game Framework Project +CompanyName=xraph +ProjectVersion=1.0.0 +Description=GameFramework - Unreal Project + +[/Script/UnrealEd.ProjectPackagingSettings] +BlueprintNativizationMethod=Disabled diff --git a/example/unreal/demo/Config/DefaultInput.ini b/example/unreal/demo/Config/DefaultInput.ini new file mode 100644 index 0000000..4124478 --- /dev/null +++ b/example/unreal/demo/Config/DefaultInput.ini @@ -0,0 +1,3 @@ +[/Script/Engine.InputSettings] +-AxisConfig=(AxisKeyName="Gamepad_LeftX",AxisProperties=(DeadZone=0.25,Sensitivity=1.f,Exponent=1.f,bInvert=False)) ++AxisConfig=(AxisKeyName="Gamepad_LeftX",AxisProperties=(DeadZone=0.25,Sensitivity=1.f,Exponent=1.f,bInvert=False)) diff --git a/example/unreal/demo/GameFrameworkProject.uproject b/example/unreal/demo/GameFrameworkProject.uproject new file mode 100644 index 0000000..f88fec3 --- /dev/null +++ b/example/unreal/demo/GameFrameworkProject.uproject @@ -0,0 +1,19 @@ +{ + "FileVersion": 3, + "EngineAssociation": "5.3", + "Category": "", + "Description": "GameFramework - Unreal Project", + "Modules": [ + { + "Name": "GameFrameworkProject", + "Type": "Runtime", + "LoadingPhase": "Default" + } + ], + "Plugins": [ + { + "Name": "FlutterPlugin", + "Enabled": true + } + ] +} diff --git a/example/unreal/demo/Plugins/FlutterPlugin/FlutterPlugin.uplugin b/example/unreal/demo/Plugins/FlutterPlugin/FlutterPlugin.uplugin new file mode 100644 index 0000000..637fd90 --- /dev/null +++ b/example/unreal/demo/Plugins/FlutterPlugin/FlutterPlugin.uplugin @@ -0,0 +1,31 @@ +{ + "FileVersion": 3, + "Version": 1, + "VersionName": "0.5.0", + "FriendlyName": "Flutter Bridge Plugin", + "Description": "Enables bidirectional communication between Flutter and Unreal Engine", + "Category": "Networking", + "CreatedBy": "xraph", + "CreatedByURL": "https://github.com/xraph/gameframework", + "DocsURL": "", + "MarketplaceURL": "", + "SupportURL": "https://github.com/xraph/gameframework/issues", + "CanContainContent": true, + "IsBetaVersion": false, + "IsExperimentalVersion": false, + "Installed": false, + "Modules": [ + { + "Name": "FlutterPlugin", + "Type": "Runtime", + "LoadingPhase": "PreDefault", + "PlatformAllowList": [ + "Win64", + "Mac", + "Linux", + "IOS", + "Android" + ] + } + ] +} diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/FlutterPlugin.Build.cs b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/FlutterPlugin.Build.cs new file mode 100644 index 0000000..372df0e --- /dev/null +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/FlutterPlugin.Build.cs @@ -0,0 +1,90 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +using UnrealBuildTool; + +public class FlutterPlugin : ModuleRules +{ + public FlutterPlugin(ReadOnlyTargetRules Target) : base(Target) + { + PCHUsage = ModuleRules.PCHUsageMode.UseExplicitOrSharedPCHs; + + PublicIncludePaths.AddRange( + new string[] { + // ... add public include paths required here ... + } + ); + + + PrivateIncludePaths.AddRange( + new string[] { + // ... add other private include paths required here ... + } + ); + + + PublicDependencyModuleNames.AddRange( + new string[] + { + "Core", + "CoreUObject", + "Engine", + "RHI", + "RenderCore", + "Json", + "JsonUtilities", + // ... add other public dependencies that you statically link with here ... + } + ); + + + PrivateDependencyModuleNames.AddRange( + new string[] + { + "Slate", + "SlateCore", + // ... add private dependencies that you statically link with here ... + } + ); + + + DynamicallyLoadedModuleNames.AddRange( + new string[] + { + // ... add any modules that your module loads dynamically here ... + } + ); + + // Platform-specific settings + if (Target.Platform == UnrealTargetPlatform.Android) + { + PrivateDependencyModuleNames.Add("Launch"); + + string PluginPath = Utils.MakePathRelativeTo(ModuleDirectory, Target.RelativeEnginePath); + AdditionalPropertiesForReceipt.Add("AndroidPlugin", System.IO.Path.Combine(PluginPath, "FlutterPlugin_Android_UPL.xml")); + } + else if (Target.Platform == UnrealTargetPlatform.IOS) + { + PublicFrameworks.AddRange( + new string[] + { + "UIKit", + "Foundation", + "Metal", + "MetalKit" + } + ); + } + else if (Target.Platform == UnrealTargetPlatform.Mac) + { + PublicFrameworks.AddRange( + new string[] + { + "Cocoa", + "Foundation", + "Metal", + "MetalKit" + } + ); + } + } +} diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/FlutterPlugin_Android_UPL.xml b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/FlutterPlugin_Android_UPL.xml new file mode 100644 index 0000000..6d1664d --- /dev/null +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/FlutterPlugin_Android_UPL.xml @@ -0,0 +1,370 @@ + + + + + + + + + + + + + + + + +-keep class com.epicgames.unreal.GameActivity { + public static <methods>; +} +-keepclasseswithmembernames class com.epicgames.unreal.GameActivity { + native <methods>; +} + + + + + + + + + + +import android.app.Activity; +import android.content.Context; +import android.os.Bundle; +import android.view.SurfaceHolder; + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/Android/FlutterBridge_Android.cpp b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/Android/FlutterBridge_Android.cpp new file mode 100644 index 0000000..0a638fa --- /dev/null +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/Android/FlutterBridge_Android.cpp @@ -0,0 +1,781 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include "FlutterBridge.h" + +#if PLATFORM_ANDROID + +#include "Android/AndroidJNI.h" +#include "Android/AndroidApplication.h" +#include "Android/AndroidWindow.h" +#include "Engine/Engine.h" +#include "Engine/GameViewportClient.h" +#include "Slate/SceneViewport.h" +#include "RenderingThread.h" +#include +#include +#include + +// Global reference to the Java UnrealEngineController instance +static jobject GUnrealEngineControllerInstance = nullptr; +static jclass GUnrealEngineControllerClass = nullptr; + +// Cached method IDs for callbacks +static jmethodID GOnMessageFromUnrealMethodID = nullptr; +static jmethodID GOnLevelLoadedMethodID = nullptr; + +// Reference to FlutterBridge instance +static AFlutterBridge* GFlutterBridgeInstance = nullptr; + +// Native window for rendering (from Flutter's SurfaceView) +static ANativeWindow* GNativeWindow = nullptr; +static int32 GSurfaceWidth = 0; +static int32 GSurfaceHeight = 0; + +// ============================================================ +// MARK: - Helper Functions +// ============================================================ + +/** + * Convert FString to jstring + */ +jstring FStringToJString(JNIEnv* Env, const FString& String) +{ + if (!Env) + { + return nullptr; + } + + // Use FTCHARToUTF8 converter to avoid dangling pointer from TCHAR_TO_UTF8 macro + FTCHARToUTF8 Converter(*String); + return Env->NewStringUTF(Converter.Get()); +} + +/** + * Convert jstring to FString + */ +FString JStringToFString(JNIEnv* Env, jstring JavaString) +{ + if (!Env || !JavaString) + { + return FString(); + } + + const char* UTFString = Env->GetStringUTFChars(JavaString, nullptr); + FString Result(UTF8_TO_TCHAR(UTFString)); + Env->ReleaseStringUTFChars(JavaString, UTFString); + + return Result; +} + +/** + * Convert Java Map to TMap + */ +TMap JMapToTMap(JNIEnv* Env, jobject JavaMap) +{ + TMap Result; + + if (!Env || !JavaMap) + { + return Result; + } + + // Get Map class and methods + jclass MapClass = Env->FindClass("java/util/Map"); + jmethodID EntrySetMethod = Env->GetMethodID(MapClass, "entrySet", "()Ljava/util/Set;"); + + // Get Set class and methods + jclass SetClass = Env->FindClass("java/util/Set"); + jmethodID IteratorMethod = Env->GetMethodID(SetClass, "iterator", "()Ljava/util/Iterator;"); + + // Get Iterator class and methods + jclass IteratorClass = Env->FindClass("java/util/Iterator"); + jmethodID HasNextMethod = Env->GetMethodID(IteratorClass, "hasNext", "()Z"); + jmethodID NextMethod = Env->GetMethodID(IteratorClass, "next", "()Ljava/lang/Object;"); + + // Get Map.Entry class and methods + jclass EntryClass = Env->FindClass("java/util/Map$Entry"); + jmethodID GetKeyMethod = Env->GetMethodID(EntryClass, "getKey", "()Ljava/lang/Object;"); + jmethodID GetValueMethod = Env->GetMethodID(EntryClass, "getValue", "()Ljava/lang/Object;"); + + // Iterate through the map + jobject EntrySet = Env->CallObjectMethod(JavaMap, EntrySetMethod); + jobject Iterator = Env->CallObjectMethod(EntrySet, IteratorMethod); + + while (Env->CallBooleanMethod(Iterator, HasNextMethod)) + { + jobject Entry = Env->CallObjectMethod(Iterator, NextMethod); + jstring Key = (jstring)Env->CallObjectMethod(Entry, GetKeyMethod); + jobject Value = Env->CallObjectMethod(Entry, GetValueMethod); + + FString KeyString = JStringToFString(Env, Key); + FString ValueString; + + // Handle different value types + if (Value) + { + jclass ObjectClass = Env->GetObjectClass(Value); + jmethodID ToStringMethod = Env->GetMethodID(ObjectClass, "toString", "()Ljava/lang/String;"); + jstring ValueStr = (jstring)Env->CallObjectMethod(Value, ToStringMethod); + ValueString = JStringToFString(Env, ValueStr); + Env->DeleteLocalRef(ValueStr); + Env->DeleteLocalRef(ObjectClass); + } + + Result.Add(KeyString, ValueString); + + Env->DeleteLocalRef(Key); + Env->DeleteLocalRef(Entry); + } + + Env->DeleteLocalRef(Iterator); + Env->DeleteLocalRef(EntrySet); + Env->DeleteLocalRef(MapClass); + Env->DeleteLocalRef(SetClass); + Env->DeleteLocalRef(IteratorClass); + Env->DeleteLocalRef(EntryClass); + + return Result; +} + +/** + * Convert TMap to Java HashMap + */ +jobject TMapToJMap(JNIEnv* Env, const TMap& Map) +{ + if (!Env) + { + return nullptr; + } + + // Create HashMap + jclass HashMapClass = Env->FindClass("java/util/HashMap"); + jmethodID HashMapConstructor = Env->GetMethodID(HashMapClass, "", "()V"); + jmethodID PutMethod = Env->GetMethodID(HashMapClass, "put", "(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;"); + + jobject HashMap = Env->NewObject(HashMapClass, HashMapConstructor); + + // Get Integer class + jclass IntegerClass = Env->FindClass("java/lang/Integer"); + jmethodID IntegerConstructor = Env->GetMethodID(IntegerClass, "", "(I)V"); + + // Add all entries + for (const auto& Entry : Map) + { + jstring Key = FStringToJString(Env, Entry.Key); + jobject Value = Env->NewObject(IntegerClass, IntegerConstructor, Entry.Value); + Env->CallObjectMethod(HashMap, PutMethod, Key, Value); + Env->DeleteLocalRef(Key); + Env->DeleteLocalRef(Value); + } + + Env->DeleteLocalRef(HashMapClass); + Env->DeleteLocalRef(IntegerClass); + + return HashMap; +} + +// ============================================================ +// MARK: - JNI Native Method Implementations +// ============================================================ + +extern "C" +{ + /** + * Create Unreal Engine instance + */ + JNIEXPORT jboolean JNICALL + Java_com_xraph_gameframework_unreal_UnrealEngineController_nativeCreate( + JNIEnv* Env, jobject Obj, jobject Config) + { + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] nativeCreate called")); + + // Store controller instance + if (!GUnrealEngineControllerInstance) + { + GUnrealEngineControllerInstance = Env->NewGlobalRef(Obj); + GUnrealEngineControllerClass = (jclass)Env->NewGlobalRef(Env->GetObjectClass(Obj)); + + // Cache method IDs + GOnMessageFromUnrealMethodID = Env->GetMethodID( + GUnrealEngineControllerClass, + "onMessageFromUnreal", + "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V" + ); + + GOnLevelLoadedMethodID = Env->GetMethodID( + GUnrealEngineControllerClass, + "onLevelLoaded", + "(Ljava/lang/String;I)V" + ); + } + + // Parse config (if needed) + // TMap ConfigMap = JMapToTMap(Env, Config); + + // Unreal Engine initialization happens automatically + // This is called after Unreal has already started + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] Unreal Engine initialized")); + + return true; + } + + /** + * Get the native Unreal view + * + * On Android, Unreal uses a NativeActivity with its own SurfaceView. + * We try to get the content view from the current activity's window. + */ + JNIEXPORT jobject JNICALL + Java_com_xraph_gameframework_unreal_UnrealEngineController_nativeGetView( + JNIEnv* Env, jobject Obj) + { + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] nativeGetView called")); + + // Get the main activity from AndroidApplication + jobject Activity = FAndroidApplication::GetGameActivityThis(); + if (!Activity) + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterBridge_Android] No game activity found")); + return nullptr; + } + + // Try to get the content view from the activity's window + // Activity.getWindow().getDecorView() + jclass ActivityClass = Env->GetObjectClass(Activity); + if (!ActivityClass) + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterBridge_Android] Failed to get activity class")); + return nullptr; + } + + jmethodID GetWindowMethod = Env->GetMethodID(ActivityClass, "getWindow", "()Landroid/view/Window;"); + if (!GetWindowMethod) + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterBridge_Android] Failed to get getWindow method")); + Env->DeleteLocalRef(ActivityClass); + return nullptr; + } + + jobject Window = Env->CallObjectMethod(Activity, GetWindowMethod); + if (!Window) + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterBridge_Android] Failed to get window")); + Env->DeleteLocalRef(ActivityClass); + return nullptr; + } + + jclass WindowClass = Env->GetObjectClass(Window); + jmethodID GetDecorViewMethod = Env->GetMethodID(WindowClass, "getDecorView", "()Landroid/view/View;"); + if (!GetDecorViewMethod) + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterBridge_Android] Failed to get getDecorView method")); + Env->DeleteLocalRef(WindowClass); + Env->DeleteLocalRef(Window); + Env->DeleteLocalRef(ActivityClass); + return nullptr; + } + + jobject DecorView = Env->CallObjectMethod(Window, GetDecorViewMethod); + + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] Got decor view: %p"), DecorView); + + // Clean up local references + Env->DeleteLocalRef(WindowClass); + Env->DeleteLocalRef(Window); + Env->DeleteLocalRef(ActivityClass); + + // Return a global reference to the view + if (DecorView) + { + return Env->NewGlobalRef(DecorView); + } + + return nullptr; + } + + /** + * Set the rendering surface from Kotlin's SurfaceView + * + * This is the key function for Flutter integration - Kotlin creates a SurfaceView + * and passes the Surface to native code. We convert it to ANativeWindow which + * can be used by Unreal for rendering. + * + * @param Surface The Android Surface object, or null to clear + */ + JNIEXPORT void JNICALL + Java_com_xraph_gameframework_unreal_UnrealEngineController_nativeSetSurface( + JNIEnv* Env, jobject Obj, jobject Surface) + { + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] nativeSetSurface called")); + + // Release previous native window if any + if (GNativeWindow != nullptr) + { + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] Releasing previous native window")); + ANativeWindow_release(GNativeWindow); + GNativeWindow = nullptr; + } + + if (Surface != nullptr) + { + // Get ANativeWindow from the Surface + GNativeWindow = ANativeWindow_fromSurface(Env, Surface); + + if (GNativeWindow != nullptr) + { + // Get surface dimensions + GSurfaceWidth = ANativeWindow_getWidth(GNativeWindow); + GSurfaceHeight = ANativeWindow_getHeight(GNativeWindow); + + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] Native window created: %dx%d"), + GSurfaceWidth, GSurfaceHeight); + + // Configure Unreal to render to this window + // This is the key integration point - tell Unreal's Android window system + // about our external surface + + // Acquire an additional reference since Unreal will manage this + ANativeWindow_acquire(GNativeWindow); + + // Try to set the hardware window for rendering + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] Setting hardware window...")); + + // Check if window is different from current + void* CurrentWindow = FAndroidWindow::GetHardwareWindow_EventThread(); + if (CurrentWindow != GNativeWindow) + { + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] Calling SetHardwareWindow_EventThread")); + + // Set the hardware window - this tells Unreal's RHI where to render + FAndroidWindow::SetHardwareWindow_EventThread(GNativeWindow); + + // Set window dimensions to trigger proper initialization + FAndroidWindow::SetWindowDimensions_EventThread(GNativeWindow); + + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] Hardware window set: %dx%d"), + GSurfaceWidth, GSurfaceHeight); + } + else + { + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] Hardware window already set, updating dimensions")); + FAndroidWindow::SetWindowDimensions_EventThread(GNativeWindow); + } + + // Notify FlutterBridge if available + if (GFlutterBridgeInstance) + { + GFlutterBridgeInstance->OnSurfaceReady(GSurfaceWidth, GSurfaceHeight); + } + } + else + { + UE_LOG(LogTemp, Error, TEXT("[FlutterBridge_Android] Failed to get native window from surface")); + } + } + else + { + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] Surface cleared")); + GSurfaceWidth = 0; + GSurfaceHeight = 0; + + // Clear the hardware window + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] Clearing hardware window")); + FAndroidWindow::SetHardwareWindow_EventThread(nullptr); + + // Notify FlutterBridge if available + if (GFlutterBridgeInstance) + { + GFlutterBridgeInstance->OnSurfaceDestroyed(); + } + } + } + + /** + * Handle surface dimension changes + * + * Called when the SurfaceView dimensions change (e.g., rotation, resize) + * + * @param Width New surface width + * @param Height New surface height + */ + JNIEXPORT void JNICALL + Java_com_xraph_gameframework_unreal_UnrealEngineController_nativeSurfaceChanged( + JNIEnv* Env, jobject Obj, jint Width, jint Height) + { + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] nativeSurfaceChanged: %dx%d"), Width, Height); + + GSurfaceWidth = Width; + GSurfaceHeight = Height; + + if (GNativeWindow != nullptr) + { + // Update the native window buffer geometry if needed + // ANativeWindow_setBuffersGeometry(GNativeWindow, Width, Height, WINDOW_FORMAT_RGBA_8888); + + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] Surface dimensions updated")); + } + + // Notify FlutterBridge of size change + if (GFlutterBridgeInstance) + { + GFlutterBridgeInstance->OnSurfaceSizeChanged(Width, Height); + } + } + + /** + * Get the current native window (for use by other Unreal systems) + */ + ANativeWindow* FlutterBridge_GetNativeWindow() + { + return GNativeWindow; + } + + /** + * Get the current surface dimensions + */ + void FlutterBridge_GetSurfaceSize(int32& OutWidth, int32& OutHeight) + { + OutWidth = GSurfaceWidth; + OutHeight = GSurfaceHeight; + } + + /** + * Pause the engine + */ + JNIEXPORT void JNICALL + Java_com_xraph_gameframework_unreal_UnrealEngineController_nativePause( + JNIEnv* Env, jobject Obj) + { + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] nativePause called")); + + if (GFlutterBridgeInstance) + { + GFlutterBridgeInstance->OnEnginePause(); + } + + // Pause Unreal Engine rendering + // This will be handled by Unreal's lifecycle automatically + } + + /** + * Resume the engine + */ + JNIEXPORT void JNICALL + Java_com_xraph_gameframework_unreal_UnrealEngineController_nativeResume( + JNIEnv* Env, jobject Obj) + { + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] nativeResume called")); + + if (GFlutterBridgeInstance) + { + GFlutterBridgeInstance->OnEngineResume(); + } + + // Resume Unreal Engine rendering + // This will be handled by Unreal's lifecycle automatically + } + + /** + * Quit the engine + */ + JNIEXPORT void JNICALL + Java_com_xraph_gameframework_unreal_UnrealEngineController_nativeQuit( + JNIEnv* Env, jobject Obj) + { + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] nativeQuit called")); + + if (GFlutterBridgeInstance) + { + GFlutterBridgeInstance->OnEngineQuit(); + } + + // Clean up global references + if (GUnrealEngineControllerInstance) + { + Env->DeleteGlobalRef(GUnrealEngineControllerInstance); + GUnrealEngineControllerInstance = nullptr; + } + + if (GUnrealEngineControllerClass) + { + Env->DeleteGlobalRef(GUnrealEngineControllerClass); + GUnrealEngineControllerClass = nullptr; + } + + GFlutterBridgeInstance = nullptr; + } + + /** + * Send message to Unreal + */ + JNIEXPORT void JNICALL + Java_com_xraph_gameframework_unreal_UnrealEngineController_nativeSendMessage( + JNIEnv* Env, jobject Obj, jstring Target, jstring Method, jstring Data) + { + FString TargetString = JStringToFString(Env, Target); + FString MethodString = JStringToFString(Env, Method); + FString DataString = JStringToFString(Env, Data); + + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] nativeSendMessage: Target=%s, Method=%s"), + *TargetString, *MethodString); + + if (GFlutterBridgeInstance) + { + GFlutterBridgeInstance->ReceiveFromFlutter(TargetString, MethodString, DataString); + } + else + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterBridge_Android] FlutterBridge instance not set")); + } + } + + /** + * Execute console command + */ + JNIEXPORT void JNICALL + Java_com_xraph_gameframework_unreal_UnrealEngineController_nativeExecuteConsoleCommand( + JNIEnv* Env, jobject Obj, jstring Command) + { + FString CommandString = JStringToFString(Env, Command); + + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] nativeExecuteConsoleCommand: %s"), *CommandString); + + if (GFlutterBridgeInstance) + { + GFlutterBridgeInstance->ExecuteConsoleCommand(CommandString); + } + else + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterBridge_Android] FlutterBridge instance not set")); + } + } + + /** + * Load level + */ + JNIEXPORT void JNICALL + Java_com_xraph_gameframework_unreal_UnrealEngineController_nativeLoadLevel( + JNIEnv* Env, jobject Obj, jstring LevelName) + { + FString LevelNameString = JStringToFString(Env, LevelName); + + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] nativeLoadLevel: %s"), *LevelNameString); + + if (GFlutterBridgeInstance) + { + GFlutterBridgeInstance->LoadLevel(LevelNameString); + } + else + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterBridge_Android] FlutterBridge instance not set")); + } + } + + /** + * Apply quality settings + */ + JNIEXPORT void JNICALL + Java_com_xraph_gameframework_unreal_UnrealEngineController_nativeApplyQualitySettings( + JNIEnv* Env, jobject Obj, jobject Settings) + { + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] nativeApplyQualitySettings called")); + + if (!GFlutterBridgeInstance) + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterBridge_Android] FlutterBridge instance not set")); + return; + } + + // Parse settings map + TMap SettingsMap = JMapToTMap(Env, Settings); + + // Extract quality settings + int32 QualityLevel = SettingsMap.Contains(TEXT("qualityLevel")) ? + FCString::Atoi(*SettingsMap[TEXT("qualityLevel")]) : -1; + int32 AntiAliasing = SettingsMap.Contains(TEXT("antiAliasingQuality")) ? + FCString::Atoi(*SettingsMap[TEXT("antiAliasingQuality")]) : -1; + int32 Shadow = SettingsMap.Contains(TEXT("shadowQuality")) ? + FCString::Atoi(*SettingsMap[TEXT("shadowQuality")]) : -1; + int32 PostProcess = SettingsMap.Contains(TEXT("postProcessQuality")) ? + FCString::Atoi(*SettingsMap[TEXT("postProcessQuality")]) : -1; + int32 Texture = SettingsMap.Contains(TEXT("textureQuality")) ? + FCString::Atoi(*SettingsMap[TEXT("textureQuality")]) : -1; + int32 Effects = SettingsMap.Contains(TEXT("effectsQuality")) ? + FCString::Atoi(*SettingsMap[TEXT("effectsQuality")]) : -1; + int32 Foliage = SettingsMap.Contains(TEXT("foliageQuality")) ? + FCString::Atoi(*SettingsMap[TEXT("foliageQuality")]) : -1; + int32 ViewDistance = SettingsMap.Contains(TEXT("viewDistanceQuality")) ? + FCString::Atoi(*SettingsMap[TEXT("viewDistanceQuality")]) : -1; + + // Apply settings + GFlutterBridgeInstance->ApplyQualitySettings( + QualityLevel, + AntiAliasing, + Shadow, + PostProcess, + Texture, + Effects, + Foliage, + ViewDistance + ); + } + + /** + * Get quality settings + */ + JNIEXPORT jobject JNICALL + Java_com_xraph_gameframework_unreal_UnrealEngineController_nativeGetQualitySettings( + JNIEnv* Env, jobject Obj) + { + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] nativeGetQualitySettings called")); + + if (!GFlutterBridgeInstance) + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterBridge_Android] FlutterBridge instance not set")); + return nullptr; + } + + // Get quality settings from Unreal + TMap Settings = GFlutterBridgeInstance->GetQualitySettings(); + + // Convert to Java HashMap + return TMapToJMap(Env, Settings); + } +} + +// ============================================================ +// MARK: - Callbacks from Unreal to Flutter (via Java) +// ============================================================ + +/** + * Send message to Flutter via Java + * Called from AFlutterBridge::SendToFlutter() + */ +void FlutterBridge_SendToFlutter_Android(const FString& Target, const FString& Method, const FString& Data) +{ + if (!GUnrealEngineControllerInstance || !GOnMessageFromUnrealMethodID) + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterBridge_Android] Cannot send to Flutter: Java instance not initialized")); + return; + } + + JNIEnv* Env = FAndroidApplication::GetJavaEnv(); + if (!Env) + { + UE_LOG(LogTemp, Error, TEXT("[FlutterBridge_Android] Failed to get JNI environment")); + return; + } + + // Convert strings + jstring jTarget = FStringToJString(Env, Target); + jstring jMethod = FStringToJString(Env, Method); + jstring jData = FStringToJString(Env, Data); + + // Call Java method + Env->CallVoidMethod( + GUnrealEngineControllerInstance, + GOnMessageFromUnrealMethodID, + jTarget, + jMethod, + jData + ); + + // Clean up local references + Env->DeleteLocalRef(jTarget); + Env->DeleteLocalRef(jMethod); + Env->DeleteLocalRef(jData); + + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] Message sent to Flutter: Target=%s, Method=%s"), + *Target, *Method); +} + +/** + * Send binary data to Flutter via Java + * Called from AFlutterBridge::SendBinaryToFlutter() + */ +void FlutterBridge_SendBinaryToFlutter_Android(const FString& Target, const FString& Method, const TArray& Data, int32 Checksum) +{ + if (!GUnrealEngineControllerInstance) + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterBridge_Android] Cannot send binary to Flutter: Java instance not initialized")); + return; + } + + JNIEnv* Env = FAndroidApplication::GetJavaEnv(); + if (!Env) + { + UE_LOG(LogTemp, Error, TEXT("[FlutterBridge_Android] Failed to get JNI environment")); + return; + } + + // Convert strings + jstring jTarget = FStringToJString(Env, Target); + jstring jMethod = FStringToJString(Env, Method); + + // Create byte array + jbyteArray jData = Env->NewByteArray(Data.Num()); + if (jData && Data.Num() > 0) + { + Env->SetByteArrayRegion(jData, 0, Data.Num(), reinterpret_cast(Data.GetData())); + } + + // TODO: Call Java method for binary data once implemented in UnrealEngineController + // For now, log the binary data info + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] Binary data to Flutter: Target=%s, Method=%s, Size=%d, Checksum=%d"), + *Target, *Method, Data.Num(), Checksum); + + // Clean up local references + Env->DeleteLocalRef(jTarget); + Env->DeleteLocalRef(jMethod); + if (jData) + { + Env->DeleteLocalRef(jData); + } +} + +/** + * Notify Flutter that a level has been loaded + */ +void FlutterBridge_NotifyLevelLoaded_Android(const FString& LevelName, int32 BuildIndex) +{ + if (!GUnrealEngineControllerInstance || !GOnLevelLoadedMethodID) + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterBridge_Android] Cannot notify level loaded: Java instance not initialized")); + return; + } + + JNIEnv* Env = FAndroidApplication::GetJavaEnv(); + if (!Env) + { + UE_LOG(LogTemp, Error, TEXT("[FlutterBridge_Android] Failed to get JNI environment")); + return; + } + + // Convert level name + jstring jLevelName = FStringToJString(Env, LevelName); + + // Call Java method + Env->CallVoidMethod( + GUnrealEngineControllerInstance, + GOnLevelLoadedMethodID, + jLevelName, + BuildIndex + ); + + // Clean up local reference + Env->DeleteLocalRef(jLevelName); + + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] Level loaded notification sent: %s"), *LevelName); +} + +/** + * Set the FlutterBridge instance + * Called from AFlutterBridge::BeginPlay() + */ +void FlutterBridge_SetInstance_Android(AFlutterBridge* Instance) +{ + GFlutterBridgeInstance = Instance; + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] FlutterBridge instance set")); +} + +#endif // PLATFORM_ANDROID diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterAssetManager.cpp b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterAssetManager.cpp new file mode 100644 index 0000000..93e0052 --- /dev/null +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterAssetManager.cpp @@ -0,0 +1,486 @@ +#include "FlutterAssetManager.h" +#include "FlutterBridge.h" +#include "Engine/World.h" +#include "Engine/Engine.h" +#include "Kismet/GameplayStatics.h" +#include "Engine/LevelStreaming.h" +#include "Misc/Paths.h" + +UFlutterAssetManager* UFlutterAssetManager::Instance = nullptr; + +UFlutterAssetManager::UFlutterAssetManager() +{ + Statistics = FFlutterAssetStatistics(); + CurrentProgress = FFlutterAssetProgress(); +} + +UFlutterAssetManager* UFlutterAssetManager::Get(UObject* WorldContextObject) +{ + if (!Instance) + { + Instance = NewObject(); + Instance->AddToRoot(); + } + return Instance; +} + +// ==================== ASSET LOADING ==================== + +void UFlutterAssetManager::LoadAsset(const FString& AssetPath) +{ + if (AssetPath.IsEmpty()) + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterAssetManager] Empty asset path provided")); + return; + } + + // Check if already loaded + if (FFlutterLoadedAsset* ExistingAsset = LoadedAssets.Find(AssetPath)) + { + if (ExistingAsset->State == EFlutterAssetState::Loaded) + { + Statistics.CacheHits++; + OnAssetLoaded.Broadcast(AssetPath, ExistingAsset->Asset); + NotifyFlutterAssetLoaded(AssetPath); + return; + } + } + + Statistics.CacheMisses++; + + // Check if already loading + if (PendingLoads.Contains(AssetPath)) + { + UE_LOG(LogTemp, Log, TEXT("[FlutterAssetManager] Asset already loading: %s"), *AssetPath); + return; + } + + // Create loading entry + FFlutterLoadedAsset LoadingAsset; + LoadingAsset.AssetPath = AssetPath; + LoadingAsset.State = EFlutterAssetState::Loading; + LoadedAssets.Add(AssetPath, LoadingAsset); + + // Record start time for statistics + double StartTime = FPlatformTime::Seconds(); + + // Start async load + FSoftObjectPath SoftPath(AssetPath); + TSharedPtr Handle = StreamableManager.RequestAsyncLoad( + SoftPath, + FStreamableDelegate::CreateLambda([this, AssetPath, StartTime]() + { + // Calculate load time + int64 LoadTimeMs = (int64)((FPlatformTime::Seconds() - StartTime) * 1000.0); + + // Get the loaded asset + FSoftObjectPath SoftPath(AssetPath); + UObject* LoadedObject = SoftPath.ResolveObject(); + + if (LoadedObject) + { + // Update loaded asset entry + if (FFlutterLoadedAsset* Entry = LoadedAssets.Find(AssetPath)) + { + Entry->Asset = LoadedObject; + Entry->State = EFlutterAssetState::Loaded; + Entry->LoadTimeMs = LoadTimeMs; + Entry->SizeBytes = EstimateAssetSize(LoadedObject); + + // Update statistics + Statistics.TotalAssetsLoaded++; + Statistics.TotalBytesLoaded += Entry->SizeBytes; + Statistics.CurrentMemoryUsage += Entry->SizeBytes; + + // Update average load time + float TotalTime = Statistics.AverageLoadTimeMs * (Statistics.TotalAssetsLoaded - 1) + LoadTimeMs; + Statistics.AverageLoadTimeMs = TotalTime / Statistics.TotalAssetsLoaded; + } + + HandleAssetLoaded(AssetPath, LoadedObject); + } + else + { + // Handle failure + if (FFlutterLoadedAsset* Entry = LoadedAssets.Find(AssetPath)) + { + Entry->State = EFlutterAssetState::Failed; + } + + FString ErrorMessage = FString::Printf(TEXT("Failed to resolve asset: %s"), *AssetPath); + OnAssetFailed.Broadcast(AssetPath, ErrorMessage); + NotifyFlutterAssetFailed(AssetPath, ErrorMessage); + } + + // Remove from pending + PendingLoads.Remove(AssetPath); + UpdateProgress(); + }) + ); + + PendingLoads.Add(AssetPath, Handle); +} + +UObject* UFlutterAssetManager::LoadAssetSync(const FString& AssetPath) +{ + if (AssetPath.IsEmpty()) + { + return nullptr; + } + + // Check cache first + if (FFlutterLoadedAsset* ExistingAsset = LoadedAssets.Find(AssetPath)) + { + if (ExistingAsset->State == EFlutterAssetState::Loaded && ExistingAsset->Asset) + { + Statistics.CacheHits++; + return ExistingAsset->Asset; + } + } + + Statistics.CacheMisses++; + + // Synchronous load + FSoftObjectPath SoftPath(AssetPath); + UObject* LoadedObject = StreamableManager.LoadSynchronous(SoftPath); + + if (LoadedObject) + { + FFlutterLoadedAsset LoadedEntry; + LoadedEntry.AssetPath = AssetPath; + LoadedEntry.Asset = LoadedObject; + LoadedEntry.State = EFlutterAssetState::Loaded; + LoadedEntry.SizeBytes = EstimateAssetSize(LoadedObject); + + LoadedAssets.Add(AssetPath, LoadedEntry); + + Statistics.TotalAssetsLoaded++; + Statistics.TotalBytesLoaded += LoadedEntry.SizeBytes; + Statistics.CurrentMemoryUsage += LoadedEntry.SizeBytes; + } + + return LoadedObject; +} + +void UFlutterAssetManager::LoadAssets(const TArray& AssetPaths) +{ + if (AssetPaths.Num() == 0) + { + return; + } + + // Initialize batch progress + BatchLoadPaths = AssetPaths; + CurrentProgress.TotalAssets = AssetPaths.Num(); + CurrentProgress.LoadedAssets = 0; + CurrentProgress.FailedAssets = 0; + CurrentProgress.Progress = 0.0f; + + // Start loading all assets + for (const FString& Path : AssetPaths) + { + LoadAsset(Path); + } +} + +void UFlutterAssetManager::LoadLevel(const FString& LevelName, bool bAbsolute) +{ + UE_LOG(LogTemp, Log, TEXT("[FlutterAssetManager] Loading level: %s"), *LevelName); + + UWorld* World = GEngine ? GEngine->GetWorldContexts()[0].World() : nullptr; + if (World) + { + UGameplayStatics::OpenLevel(World, *LevelName, bAbsolute); + + // Notify Flutter + if (AFlutterBridge* Bridge = AFlutterBridge::GetInstance(World)) + { + Bridge->SendToFlutter(TEXT("AssetManager"), TEXT("onLevelLoaded"), LevelName); + } + } +} + +void UFlutterAssetManager::LoadLevelAsync(const FString& LevelName) +{ + UE_LOG(LogTemp, Log, TEXT("[FlutterAssetManager] Loading level async: %s"), *LevelName); + + UWorld* World = GEngine ? GEngine->GetWorldContexts()[0].World() : nullptr; + if (World) + { + FLatentActionInfo LatentInfo; + LatentInfo.CallbackTarget = this; + LatentInfo.UUID = GetUniqueID(); + LatentInfo.Linkage = 0; + + UGameplayStatics::LoadStreamLevel(World, FName(*LevelName), true, false, LatentInfo); + } +} + +// ==================== ASSET UNLOADING ==================== + +void UFlutterAssetManager::UnloadAsset(const FString& AssetPath) +{ + if (FFlutterLoadedAsset* Entry = LoadedAssets.Find(AssetPath)) + { + // Update statistics + Statistics.TotalAssetsUnloaded++; + Statistics.CurrentMemoryUsage -= Entry->SizeBytes; + + // Remove from loaded assets + LoadedAssets.Remove(AssetPath); + + // Broadcast event + OnAssetUnloaded.Broadcast(AssetPath); + + UE_LOG(LogTemp, Log, TEXT("[FlutterAssetManager] Unloaded asset: %s"), *AssetPath); + } +} + +void UFlutterAssetManager::UnloadAssets(const TArray& AssetPaths) +{ + for (const FString& Path : AssetPaths) + { + UnloadAsset(Path); + } +} + +void UFlutterAssetManager::UnloadAllAssets() +{ + TArray AllPaths; + LoadedAssets.GetKeys(AllPaths); + UnloadAssets(AllPaths); +} + +void UFlutterAssetManager::UnloadLevel(const FString& LevelName) +{ + UWorld* World = GEngine ? GEngine->GetWorldContexts()[0].World() : nullptr; + if (World) + { + FLatentActionInfo LatentInfo; + LatentInfo.CallbackTarget = this; + LatentInfo.UUID = GetUniqueID(); + LatentInfo.Linkage = 1; + + UGameplayStatics::UnloadStreamLevel(World, FName(*LevelName), LatentInfo, false); + } +} + +// ==================== ASSET QUERIES ==================== + +bool UFlutterAssetManager::IsAssetLoaded(const FString& AssetPath) const +{ + const FFlutterLoadedAsset* Entry = LoadedAssets.Find(AssetPath); + return Entry && Entry->State == EFlutterAssetState::Loaded; +} + +EFlutterAssetState UFlutterAssetManager::GetAssetState(const FString& AssetPath) const +{ + const FFlutterLoadedAsset* Entry = LoadedAssets.Find(AssetPath); + return Entry ? Entry->State : EFlutterAssetState::NotLoaded; +} + +UObject* UFlutterAssetManager::GetLoadedAsset(const FString& AssetPath) const +{ + const FFlutterLoadedAsset* Entry = LoadedAssets.Find(AssetPath); + return (Entry && Entry->State == EFlutterAssetState::Loaded) ? Entry->Asset : nullptr; +} + +FFlutterLoadedAsset UFlutterAssetManager::GetAssetInfo(const FString& AssetPath) const +{ + const FFlutterLoadedAsset* Entry = LoadedAssets.Find(AssetPath); + return Entry ? *Entry : FFlutterLoadedAsset(); +} + +TArray UFlutterAssetManager::GetLoadedAssetPaths() const +{ + TArray Paths; + for (const auto& Pair : LoadedAssets) + { + if (Pair.Value.State == EFlutterAssetState::Loaded) + { + Paths.Add(Pair.Key); + } + } + return Paths; +} + +// ==================== CACHE MANAGEMENT ==================== + +void UFlutterAssetManager::SetCacheMaxSize(int64 MaxSizeBytes) +{ + CacheMaxSizeBytes = MaxSizeBytes; + TrimCache(); +} + +int64 UFlutterAssetManager::GetCacheSize() const +{ + return Statistics.CurrentMemoryUsage; +} + +void UFlutterAssetManager::ClearCache() +{ + UnloadAllAssets(); + Statistics.CurrentMemoryUsage = 0; +} + +void UFlutterAssetManager::TrimCache() +{ + // If we're over the limit, unload oldest assets + while (Statistics.CurrentMemoryUsage > CacheMaxSizeBytes && LoadedAssets.Num() > 0) + { + // Find the oldest loaded asset (simple LRU - could be improved) + FString OldestPath; + int64 OldestTime = INT64_MAX; + + for (const auto& Pair : LoadedAssets) + { + if (Pair.Value.State == EFlutterAssetState::Loaded && Pair.Value.LoadTimeMs < OldestTime) + { + OldestTime = Pair.Value.LoadTimeMs; + OldestPath = Pair.Key; + } + } + + if (!OldestPath.IsEmpty()) + { + UnloadAsset(OldestPath); + } + else + { + break; + } + } +} + +// ==================== STATISTICS ==================== + +FFlutterAssetStatistics UFlutterAssetManager::GetStatistics() const +{ + return Statistics; +} + +void UFlutterAssetManager::ResetStatistics() +{ + // Keep current memory usage, reset everything else + int64 CurrentMemory = Statistics.CurrentMemoryUsage; + Statistics = FFlutterAssetStatistics(); + Statistics.CurrentMemoryUsage = CurrentMemory; +} + +// ==================== FLUTTER COMMUNICATION ==================== + +void UFlutterAssetManager::NotifyFlutterProgress(const FFlutterAssetProgress& Progress) +{ + UWorld* World = GEngine ? GEngine->GetWorldContexts()[0].World() : nullptr; + if (AFlutterBridge* Bridge = AFlutterBridge::GetInstance(World)) + { + FString ProgressJson = FString::Printf( + TEXT("{\"total\":%d,\"loaded\":%d,\"failed\":%d,\"progress\":%.2f}"), + Progress.TotalAssets, + Progress.LoadedAssets, + Progress.FailedAssets, + Progress.Progress + ); + Bridge->SendToFlutter(TEXT("AssetManager"), TEXT("onProgress"), ProgressJson); + } +} + +void UFlutterAssetManager::NotifyFlutterAssetLoaded(const FString& AssetPath) +{ + UWorld* World = GEngine ? GEngine->GetWorldContexts()[0].World() : nullptr; + if (AFlutterBridge* Bridge = AFlutterBridge::GetInstance(World)) + { + Bridge->SendToFlutter(TEXT("AssetManager"), TEXT("onAssetLoaded"), AssetPath); + } +} + +void UFlutterAssetManager::NotifyFlutterAssetFailed(const FString& AssetPath, const FString& ErrorMessage) +{ + UWorld* World = GEngine ? GEngine->GetWorldContexts()[0].World() : nullptr; + if (AFlutterBridge* Bridge = AFlutterBridge::GetInstance(World)) + { + FString ErrorJson = FString::Printf(TEXT("{\"path\":\"%s\",\"error\":\"%s\"}"), *AssetPath, *ErrorMessage); + Bridge->SendToFlutter(TEXT("AssetManager"), TEXT("onAssetFailed"), ErrorJson); + } +} + +// ==================== INTERNAL METHODS ==================== + +void UFlutterAssetManager::HandleAssetLoaded(const FString& AssetPath, UObject* Asset) +{ + OnAssetLoaded.Broadcast(AssetPath, Asset); + NotifyFlutterAssetLoaded(AssetPath); + UpdateProgress(); + + // Check if we need to trim cache + if (Statistics.CurrentMemoryUsage > CacheMaxSizeBytes) + { + TrimCache(); + } +} + +void UFlutterAssetManager::UpdateProgress() +{ + if (BatchLoadPaths.Num() == 0) + { + return; + } + + int32 Loaded = 0; + int32 Failed = 0; + int64 TotalSize = 0; + int64 LoadedSize = 0; + + for (const FString& Path : BatchLoadPaths) + { + if (const FFlutterLoadedAsset* Entry = LoadedAssets.Find(Path)) + { + TotalSize += Entry->SizeBytes; + + if (Entry->State == EFlutterAssetState::Loaded) + { + Loaded++; + LoadedSize += Entry->SizeBytes; + } + else if (Entry->State == EFlutterAssetState::Failed) + { + Failed++; + } + } + } + + CurrentProgress.LoadedAssets = Loaded; + CurrentProgress.FailedAssets = Failed; + CurrentProgress.TotalSizeBytes = TotalSize; + CurrentProgress.LoadedSizeBytes = LoadedSize; + CurrentProgress.Progress = BatchLoadPaths.Num() > 0 + ? (float)(Loaded + Failed) / (float)BatchLoadPaths.Num() + : 1.0f; + + OnProgress.Broadcast(CurrentProgress); + NotifyFlutterProgress(CurrentProgress); + + // Clear batch if complete + if (Loaded + Failed >= BatchLoadPaths.Num()) + { + BatchLoadPaths.Empty(); + } +} + +int32 UFlutterAssetManager::EstimateAssetSize(UObject* Asset) const +{ + if (!Asset) + { + return 0; + } + + // Simple size estimation - in production, use more accurate methods + int32 EstimatedSize = 1024; // Base overhead + + // Use resource size if available + FResourceSizeEx ResourceSize; + Asset->GetResourceSizeEx(ResourceSize); + EstimatedSize += (int32)ResourceSize.GetTotalMemoryBytes(); + + return EstimatedSize; +} diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBlueprintLibrary.cpp b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBlueprintLibrary.cpp new file mode 100644 index 0000000..b25c647 --- /dev/null +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBlueprintLibrary.cpp @@ -0,0 +1,238 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include "FlutterBlueprintLibrary.h" +#include "FlutterBridge.h" +#include "FlutterMessageRouter.h" +#include "Misc/Base64.h" +#include "Dom/JsonObject.h" +#include "Serialization/JsonReader.h" +#include "Serialization/JsonSerializer.h" +#include "Serialization/JsonWriter.h" + +// ============================================================ +// MARK: - Messaging +// ============================================================ + +void UFlutterBlueprintLibrary::SendFlutterMessage(const UObject* WorldContextObject, const FString& Target, const FString& Method, const FString& Data) +{ + AFlutterBridge* Bridge = GetFlutterBridge(WorldContextObject); + if (Bridge) + { + Bridge->SendToFlutter(Target, Method, Data); + } + else + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterBlueprintLibrary] Flutter bridge not available")); + } +} + +void UFlutterBlueprintLibrary::SendFlutterJsonMessage(const UObject* WorldContextObject, const FString& Target, const FString& Method, const TMap& JsonObject) +{ + FString JsonString = MapToJsonString(JsonObject); + SendFlutterMessage(WorldContextObject, Target, Method, JsonString); +} + +void UFlutterBlueprintLibrary::SendFlutterBinaryMessage(const UObject* WorldContextObject, const FString& Target, const FString& Method, const TArray& Data) +{ + AFlutterBridge* Bridge = GetFlutterBridge(WorldContextObject); + if (Bridge) + { + Bridge->SendBinaryToFlutter(Target, Method, Data); + } + else + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterBlueprintLibrary] Flutter bridge not available")); + } +} + +// ============================================================ +// MARK: - Router Registration +// ============================================================ + +void UFlutterBlueprintLibrary::RegisterFlutterTarget(const UObject* WorldContextObject, const FString& TargetName, UObject* Target, bool bIsSingleton) +{ + UFlutterMessageRouter* Router = GetFlutterRouter(WorldContextObject); + if (Router) + { + Router->RegisterTarget(TargetName, Target, bIsSingleton); + } +} + +void UFlutterBlueprintLibrary::UnregisterFlutterTarget(const UObject* WorldContextObject, const FString& TargetName) +{ + UFlutterMessageRouter* Router = GetFlutterRouter(WorldContextObject); + if (Router) + { + Router->UnregisterTarget(TargetName); + } +} + +bool UFlutterBlueprintLibrary::IsFlutterTargetRegistered(const UObject* WorldContextObject, const FString& TargetName) +{ + UFlutterMessageRouter* Router = GetFlutterRouter(WorldContextObject); + if (Router) + { + return Router->IsTargetRegistered(TargetName); + } + return false; +} + +TArray UFlutterBlueprintLibrary::GetRegisteredFlutterTargets(const UObject* WorldContextObject) +{ + UFlutterMessageRouter* Router = GetFlutterRouter(WorldContextObject); + if (Router) + { + return Router->GetRegisteredTargets(); + } + return TArray(); +} + +FFlutterRouterStatistics UFlutterBlueprintLibrary::GetFlutterRouterStatistics(const UObject* WorldContextObject) +{ + UFlutterMessageRouter* Router = GetFlutterRouter(WorldContextObject); + if (Router) + { + return Router->GetStatistics(); + } + return FFlutterRouterStatistics(); +} + +// ============================================================ +// MARK: - Quality Settings +// ============================================================ + +void UFlutterBlueprintLibrary::ApplyFlutterQualityPreset(const UObject* WorldContextObject, int32 QualityLevel) +{ + AFlutterBridge* Bridge = GetFlutterBridge(WorldContextObject); + if (Bridge) + { + Bridge->ApplyQualitySettingsBP(QualityLevel); + } +} + +void UFlutterBlueprintLibrary::ApplyFlutterQualitySettings( + const UObject* WorldContextObject, + int32 AntiAliasing, + int32 Shadows, + int32 PostProcess, + int32 Textures, + int32 Effects, + int32 Foliage, + int32 ViewDistance) +{ + AFlutterBridge* Bridge = GetFlutterBridge(WorldContextObject); + if (Bridge) + { + Bridge->ApplyQualitySettings(-1, AntiAliasing, Shadows, PostProcess, Textures, Effects, Foliage, ViewDistance); + } +} + +TMap UFlutterBlueprintLibrary::GetFlutterQualitySettings(const UObject* WorldContextObject) +{ + AFlutterBridge* Bridge = GetFlutterBridge(WorldContextObject); + if (Bridge) + { + return Bridge->GetQualitySettings(); + } + return TMap(); +} + +// ============================================================ +// MARK: - Lifecycle +// ============================================================ + +void UFlutterBlueprintLibrary::LoadFlutterLevel(const UObject* WorldContextObject, const FString& LevelName) +{ + AFlutterBridge* Bridge = GetFlutterBridge(WorldContextObject); + if (Bridge) + { + Bridge->LoadLevelBP(LevelName); + } +} + +void UFlutterBlueprintLibrary::ExecuteFlutterConsoleCommand(const UObject* WorldContextObject, const FString& Command) +{ + AFlutterBridge* Bridge = GetFlutterBridge(WorldContextObject); + if (Bridge) + { + Bridge->ExecuteConsoleCommandBP(Command); + } +} + +// ============================================================ +// MARK: - Bridge Access +// ============================================================ + +AFlutterBridge* UFlutterBlueprintLibrary::GetFlutterBridge(const UObject* WorldContextObject) +{ + return AFlutterBridge::GetInstance(WorldContextObject); +} + +UFlutterMessageRouter* UFlutterBlueprintLibrary::GetFlutterRouter(const UObject* WorldContextObject) +{ + return UFlutterMessageRouter::Get(WorldContextObject); +} + +bool UFlutterBlueprintLibrary::IsFlutterBridgeAvailable(const UObject* WorldContextObject) +{ + return GetFlutterBridge(WorldContextObject) != nullptr; +} + +// ============================================================ +// MARK: - Utilities +// ============================================================ + +FString UFlutterBlueprintLibrary::MapToJsonString(const TMap& Map) +{ + TSharedPtr JsonObject = MakeShareable(new FJsonObject); + + for (const auto& Pair : Map) + { + JsonObject->SetStringField(Pair.Key, Pair.Value); + } + + FString OutputString; + TSharedRef> Writer = TJsonWriterFactory<>::Create(&OutputString); + FJsonSerializer::Serialize(JsonObject.ToSharedRef(), Writer); + + return OutputString; +} + +TMap UFlutterBlueprintLibrary::JsonStringToMap(const FString& JsonString) +{ + TMap Result; + + TSharedPtr JsonObject; + TSharedRef> Reader = TJsonReaderFactory<>::Create(JsonString); + + if (FJsonSerializer::Deserialize(Reader, JsonObject) && JsonObject.IsValid()) + { + for (const auto& Pair : JsonObject->Values) + { + FString Value; + if (Pair.Value->TryGetString(Value)) + { + Result.Add(FString(Pair.Key), Value); + } + else + { + // Convert non-string values to string representation + Result.Add(FString(Pair.Key), Pair.Value->AsString()); + } + } + } + + return Result; +} + +FString UFlutterBlueprintLibrary::EncodeBase64(const TArray& Data) +{ + return FBase64::Encode(Data); +} + +TArray UFlutterBlueprintLibrary::DecodeBase64(const FString& Base64String) +{ + TArray Result; + FBase64::Decode(Base64String, Result); + return Result; +} diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge.cpp b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge.cpp new file mode 100644 index 0000000..c2b7077 --- /dev/null +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge.cpp @@ -0,0 +1,680 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include "FlutterBridge.h" +#include "Engine/World.h" +#include "Engine/Engine.h" +#include "Engine/GameViewportClient.h" +#include "Kismet/GameplayStatics.h" +#include "Scalability.h" +#include "GameFramework/GameUserSettings.h" + +// Initialize static instance +AFlutterBridge* AFlutterBridge::Instance = nullptr; + +// CRC32 lookup table +static const uint32 CRC32Table[256] = { + 0x00000000, 0x77073096, 0xEE0E612C, 0x990951BA, 0x076DC419, 0x706AF48F, + 0xE963A535, 0x9E6495A3, 0x0EDB8832, 0x79DCB8A4, 0xE0D5E91E, 0x97D2D988, + 0x09B64C2B, 0x7EB17CBD, 0xE7B82D07, 0x90BF1D91, 0x1DB71064, 0x6AB020F2, + 0xF3B97148, 0x84BE41DE, 0x1ADAD47D, 0x6DDDE4EB, 0xF4D4B551, 0x83D385C7, + 0x136C9856, 0x646BA8C0, 0xFD62F97A, 0x8A65C9EC, 0x14015C4F, 0x63066CD9, + 0xFA0F3D63, 0x8D080DF5, 0x3B6E20C8, 0x4C69105E, 0xD56041E4, 0xA2677172, + 0x3C03E4D1, 0x4B04D447, 0xD20D85FD, 0xA50AB56B, 0x35B5A8FA, 0x42B2986C, + 0xDBBBC9D6, 0xACBCF940, 0x32D86CE3, 0x45DF5C75, 0xDCD60DCF, 0xABD13D59, + 0x26D930AC, 0x51DE003A, 0xC8D75180, 0xBFD06116, 0x21B4F4B5, 0x56B3C423, + 0xCFBA9599, 0xB8BDA50F, 0x2802B89E, 0x5F058808, 0xC60CD9B2, 0xB10BE924, + 0x2F6F7C87, 0x58684C11, 0xC1611DAB, 0xB6662D3D, 0x76DC4190, 0x01DB7106, + 0x98D220BC, 0xEFD5102A, 0x71B18589, 0x06B6B51F, 0x9FBFE4A5, 0xE8B8D433, + 0x7807C9A2, 0x0F00F934, 0x9609A88E, 0xE10E9818, 0x7F6A0DBB, 0x086D3D2D, + 0x91646C97, 0xE6635C01, 0x6B6B51F4, 0x1C6C6162, 0x856530D8, 0xF262004E, + 0x6C0695ED, 0x1B01A57B, 0x8208F4C1, 0xF50FC457, 0x65B0D9C6, 0x12B7E950, + 0x8BBEB8EA, 0xFCB9887C, 0x62DD1DDF, 0x15DA2D49, 0x8CD37CF3, 0xFBD44C65, + 0x4DB26158, 0x3AB551CE, 0xA3BC0074, 0xD4BB30E2, 0x4ADFA541, 0x3DD895D7, + 0xA4D1C46D, 0xD3D6F4FB, 0x4369E96A, 0x346ED9FC, 0xAD678846, 0xDA60B8D0, + 0x44042D73, 0x33031DE5, 0xAA0A4C5F, 0xDD0D7CC9, 0x5005713C, 0x270241AA, + 0xBE0B1010, 0xC90C2086, 0x5768B525, 0x206F85B3, 0xB966D409, 0xCE61E49F, + 0x5EDEF90E, 0x29D9C998, 0xB0D09822, 0xC7D7A8B4, 0x59B33D17, 0x2EB40D81, + 0xB7BD5C3B, 0xC0BA6CAD, 0xEDB88320, 0x9ABFB3B6, 0x03B6E20C, 0x74B1D29A, + 0xEAD54739, 0x9DD277AF, 0x04DB2615, 0x73DC1683, 0xE3630B12, 0x94643B84, + 0x0D6D6A3E, 0x7A6A5AA8, 0xE40ECF0B, 0x9309FF9D, 0x0A00AE27, 0x7D079EB1, + 0xF00F9344, 0x8708A3D2, 0x1E01F268, 0x6906C2FE, 0xF762575D, 0x806567CB, + 0x196C3671, 0x6E6B06E7, 0xFED41B76, 0x89D32BE0, 0x10DA7A5A, 0x67DD4ACC, + 0xF9B9DF6F, 0x8EBEEFF9, 0x17B7BE43, 0x60B08ED5, 0xD6D6A3E8, 0xA1D1937E, + 0x38D8C2C4, 0x4FDFF252, 0xD1BB67F1, 0xA6BC5767, 0x3FB506DD, 0x48B2364B, + 0xD80D2BDA, 0xAF0A1B4C, 0x36034AF6, 0x41047A60, 0xDF60EFC3, 0xA867DF55, + 0x316E8EEF, 0x4669BE79, 0xCB61B38C, 0xBC66831A, 0x256FD2A0, 0x5268E236, + 0xCC0C7795, 0xBB0B4703, 0x220216B9, 0x5505262F, 0xC5BA3BBE, 0xB2BD0B28, + 0x2BB45A92, 0x5CB36A04, 0xC2D7FFA7, 0xB5D0CF31, 0x2CD99E8B, 0x5BDEAE1D, + 0x9B64C2B0, 0xEC63F226, 0x756AA39C, 0x026D930A, 0x9C0906A9, 0xEB0E363F, + 0x72076785, 0x05005713, 0x95BF4A82, 0xE2B87A14, 0x7BB12BAE, 0x0CB61B38, + 0x92D28E9B, 0xE5D5BE0D, 0x7CDCEFB7, 0x0BDBDF21, 0x86D3D2D4, 0xF1D4E242, + 0x68DDB3F8, 0x1FDA836E, 0x81BE16CD, 0xF6B9265B, 0x6FB077E1, 0x18B74777, + 0x88085AE6, 0xFF0F6A70, 0x66063BCA, 0x11010B5C, 0x8F659EFF, 0xF862AE69, + 0x616BFFD3, 0x166CCF45, 0xA00AE278, 0xD70DD2EE, 0x4E048354, 0x3903B3C2, + 0xA7672661, 0xD06016F7, 0x4969474D, 0x3E6E77DB, 0xAED16A4A, 0xD9D65ADC, + 0x40DF0B66, 0x37D83BF0, 0xA9BCAE53, 0xDEBB9EC5, 0x47B2CF7F, 0x30B5FFE9, + 0xBDBDF21C, 0xCABAC28A, 0x53B39330, 0x24B4A3A6, 0xBAD03605, 0xCDD70693, + 0x54DE5729, 0x23D967BF, 0xB3667A2E, 0xC4614AB8, 0x5D681B02, 0x2A6F2B94, + 0xB40BBE37, 0xC30C8EA1, 0x5A05DF1B, 0x2D02EF8D +}; + +AFlutterBridge::AFlutterBridge() +{ + PrimaryActorTick.bCanEverTick = true; + bIsPaused = false; + BinaryChunkSize = 65536; // 64KB default + bSurfaceReady = false; + SurfaceWidth = 0; + SurfaceHeight = 0; +} + +void AFlutterBridge::BeginPlay() +{ + Super::BeginPlay(); + + // Set as singleton instance + Instance = this; + + // Initialize platform-specific bridge + InitializePlatformBridge(); + + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] Initialized")); +} + +void AFlutterBridge::EndPlay(const EEndPlayReason::Type EndPlayReason) +{ + // Clear singleton + if (Instance == this) + { + Instance = nullptr; + } + + Super::EndPlay(EndPlayReason); +} + +void AFlutterBridge::Tick(float DeltaTime) +{ + Super::Tick(DeltaTime); +} + +// ============================================================ +// MARK: - Singleton Access +// ============================================================ + +AFlutterBridge* AFlutterBridge::GetInstance(const UObject* WorldContextObject) +{ + if (Instance) + { + return Instance; + } + + // Try to find in world + if (WorldContextObject) + { + UWorld* World = WorldContextObject->GetWorld(); + if (World) + { + TArray FoundActors; + UGameplayStatics::GetAllActorsOfClass(World, AFlutterBridge::StaticClass(), FoundActors); + + if (FoundActors.Num() > 0) + { + Instance = Cast(FoundActors[0]); + return Instance; + } + } + } + + return nullptr; +} + +// ============================================================ +// MARK: - Message Communication +// ============================================================ + +void AFlutterBridge::SendToFlutter(const FString& Target, const FString& Method, const FString& Data) +{ + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] Sending to Flutter: Target=%s, Method=%s"), *Target, *Method); + + // This will be implemented in platform-specific code + // See FlutterBridge_Android.cpp and FlutterBridge_iOS.mm +#if PLATFORM_ANDROID + // JNI call to Java: UnrealEngineController.onMessageFromUnreal() + extern void FlutterBridge_SendToFlutter_Android(const FString& Target, const FString& Method, const FString& Data); + FlutterBridge_SendToFlutter_Android(Target, Method, Data); +#elif PLATFORM_IOS + // Objective-C++ call to Swift: UnrealBridge.notifyMessage() + extern void FlutterBridge_SendToFlutter_iOS(const FString& Target, const FString& Method, const FString& Data); + FlutterBridge_SendToFlutter_iOS(Target, Method, Data); +#elif PLATFORM_MAC + // Objective-C++ call to Swift: UnrealBridge.notifyMessage() + extern void FlutterBridge_SendToFlutter_Mac(const FString& Target, const FString& Method, const FString& Data); + FlutterBridge_SendToFlutter_Mac(Target, Method, Data); +#else + UE_LOG(LogTemp, Warning, TEXT("[FlutterBridge] SendToFlutter not implemented for this platform")); +#endif +} + +void AFlutterBridge::ReceiveFromFlutter(const FString& Target, const FString& Method, const FString& Data) +{ + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] Received from Flutter: Target=%s, Method=%s"), *Target, *Method); + + // Fire Blueprint event + OnMessageFromFlutter(Target, Method, Data); +} + +// ============================================================ +// MARK: - Binary Message Communication +// ============================================================ + +void AFlutterBridge::SendBinaryToFlutter(const FString& Target, const FString& Method, const TArray& Data) +{ + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] Sending binary to Flutter: Target=%s, Method=%s, Size=%d"), *Target, *Method, Data.Num()); + + int32 Checksum = CalculateCRC32(Data); + +#if PLATFORM_ANDROID + extern void FlutterBridge_SendBinaryToFlutter_Android(const FString& Target, const FString& Method, const TArray& Data, int32 Checksum); + FlutterBridge_SendBinaryToFlutter_Android(Target, Method, Data, Checksum); +#elif PLATFORM_IOS + extern void FlutterBridge_SendBinaryToFlutter_iOS(const FString& Target, const FString& Method, const TArray& Data, int32 Checksum); + FlutterBridge_SendBinaryToFlutter_iOS(Target, Method, Data, Checksum); +#elif PLATFORM_MAC + extern void FlutterBridge_SendBinaryToFlutter_Mac(const FString& Target, const FString& Method, const TArray& Data, int32 Checksum); + FlutterBridge_SendBinaryToFlutter_Mac(Target, Method, Data, Checksum); +#else + UE_LOG(LogTemp, Warning, TEXT("[FlutterBridge] SendBinaryToFlutter not implemented for this platform")); +#endif +} + +void AFlutterBridge::ReceiveBinaryFromFlutter(const FString& Target, const FString& Method, const TArray& Data, int32 Checksum) +{ + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] Received binary from Flutter: Target=%s, Method=%s, Size=%d"), *Target, *Method, Data.Num()); + + // Verify checksum + if (!VerifyChecksum(Data, Checksum)) + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterBridge] Binary message checksum mismatch!")); + } + + // Fire Blueprint event + OnBinaryMessageFromFlutter(Target, Method, Data); +} + +void AFlutterBridge::ReceiveBinaryChunkHeader( + const FString& Target, + const FString& Method, + const FString& TransferId, + int32 TotalSize, + int32 TotalChunks, + int32 Checksum) +{ + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] Binary chunk header: TransferId=%s, TotalSize=%d, TotalChunks=%d"), *TransferId, TotalSize, TotalChunks); + + FChunkedTransfer Transfer; + Transfer.Target = Target; + Transfer.Method = Method; + Transfer.TotalSize = TotalSize; + Transfer.TotalChunks = TotalChunks; + Transfer.ExpectedChecksum = Checksum; + Transfer.ReceivedChunks = 0; + + ActiveTransfers.Add(TransferId, Transfer); +} + +void AFlutterBridge::ReceiveBinaryChunkData( + const FString& Target, + const FString& Method, + const FString& TransferId, + int32 ChunkIndex, + const TArray& Data) +{ + FChunkedTransfer* Transfer = ActiveTransfers.Find(TransferId); + if (!Transfer) + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterBridge] Unknown transfer ID: %s"), *TransferId); + return; + } + + Transfer->Chunks.Add(ChunkIndex, Data); + Transfer->ReceivedChunks++; + + // Report progress + float Progress = (float)Transfer->ReceivedChunks / (float)Transfer->TotalChunks; + OnBinaryTransferProgress(TransferId, Transfer->ReceivedChunks, Transfer->TotalChunks, Progress); + + UE_LOG(LogTemp, Verbose, TEXT("[FlutterBridge] Binary chunk data: TransferId=%s, ChunkIndex=%d, Progress=%.1f%%"), *TransferId, ChunkIndex, Progress * 100.0f); +} + +void AFlutterBridge::ReceiveBinaryChunkFooter( + const FString& Target, + const FString& Method, + const FString& TransferId, + int32 TotalChunks, + int32 Checksum) +{ + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] Binary chunk footer: TransferId=%s"), *TransferId); + + FChunkedTransfer* Transfer = ActiveTransfers.Find(TransferId); + if (!Transfer) + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterBridge] Unknown transfer ID: %s"), *TransferId); + return; + } + + // Verify all chunks received + if (Transfer->ReceivedChunks != Transfer->TotalChunks) + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterBridge] Incomplete transfer: received %d/%d chunks"), Transfer->ReceivedChunks, Transfer->TotalChunks); + } + + // Assemble the complete data + AssembleChunkedTransfer(TransferId); +} + +void AFlutterBridge::AssembleChunkedTransfer(const FString& TransferId) +{ + FChunkedTransfer* Transfer = ActiveTransfers.Find(TransferId); + if (!Transfer) + { + return; + } + + // Assemble chunks in order + TArray CompleteData; + CompleteData.Reserve(Transfer->TotalSize); + + for (int32 i = 0; i < Transfer->TotalChunks; ++i) + { + TArray* ChunkData = Transfer->Chunks.Find(i); + if (ChunkData) + { + CompleteData.Append(*ChunkData); + } + else + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterBridge] Missing chunk %d in transfer %s"), i, *TransferId); + } + } + + // Verify checksum + if (!VerifyChecksum(CompleteData, Transfer->ExpectedChecksum)) + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterBridge] Chunked transfer checksum mismatch!")); + } + + // Fire Blueprint event + OnChunkedTransferComplete(TransferId, CompleteData); + OnBinaryMessageFromFlutter(Transfer->Target, Transfer->Method, CompleteData); + + // Cleanup + ActiveTransfers.Remove(TransferId); +} + +void AFlutterBridge::SetBinaryChunkSize(int32 Size) +{ + BinaryChunkSize = FMath::Clamp(Size, 1024, 1024 * 1024); // 1KB to 1MB + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] Binary chunk size set to %d"), BinaryChunkSize); +} + +int32 AFlutterBridge::GetBinaryChunkSize() const +{ + return BinaryChunkSize; +} + +int32 AFlutterBridge::CalculateCRC32(const TArray& Data) const +{ + uint32 CRC = 0xFFFFFFFF; + + for (int32 i = 0; i < Data.Num(); ++i) + { + CRC = CRC32Table[(CRC ^ Data[i]) & 0xFF] ^ (CRC >> 8); + } + + return static_cast(CRC ^ 0xFFFFFFFF); +} + +bool AFlutterBridge::VerifyChecksum(const TArray& Data, int32 ExpectedChecksum) const +{ + return CalculateCRC32(Data) == ExpectedChecksum; +} + +TArray AFlutterBridge::CompressData(const TArray& Data) const +{ + // TODO: Implement GZip compression using zlib + // For now, return uncompressed data + return Data; +} + +TArray AFlutterBridge::DecompressData(const TArray& Data) const +{ + // Check for GZip magic number (0x1F 0x8B) + if (Data.Num() >= 2 && Data[0] == 0x1F && Data[1] == 0x8B) + { + // TODO: Implement GZip decompression using zlib + UE_LOG(LogTemp, Warning, TEXT("[FlutterBridge] GZip decompression not implemented")); + } + + // Return as-is if not compressed + return Data; +} + +// ============================================================ +// MARK: - Console Commands +// ============================================================ + +void AFlutterBridge::ExecuteConsoleCommand(const FString& Command) +{ + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] Executing console command: %s"), *Command); + + if (GEngine && GEngine->GameViewport) + { + GEngine->GameViewport->ConsoleCommand(Command); + } + else + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterBridge] Cannot execute console command: GameViewport not available")); + } +} + +void AFlutterBridge::ExecuteConsoleCommandBP(const FString& Command) +{ + ExecuteConsoleCommand(Command); +} + +// ============================================================ +// MARK: - Quality Settings +// ============================================================ + +void AFlutterBridge::ApplyQualitySettings( + int32 QualityLevel, + int32 AntiAliasing, + int32 Shadow, + int32 PostProcess, + int32 Texture, + int32 Effects, + int32 Foliage, + int32 ViewDistance) +{ + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] Applying quality settings: Level=%d"), QualityLevel); + + // Apply overall quality level if specified + if (QualityLevel >= 0) + { + SetScalabilityQuality(QualityLevel); + } + + // Apply individual settings if specified + if (AntiAliasing >= 0) SetAntiAliasingQuality(AntiAliasing); + if (Shadow >= 0) SetShadowQuality(Shadow); + if (PostProcess >= 0) SetPostProcessQuality(PostProcess); + if (Texture >= 0) SetTextureQuality(Texture); + if (Effects >= 0) SetEffectsQuality(Effects); + if (Foliage >= 0) SetFoliageQuality(Foliage); + if (ViewDistance >= 0) SetViewDistanceQuality(ViewDistance); + + // Save settings + if (UGameUserSettings* Settings = GEngine->GetGameUserSettings()) + { + Settings->ApplySettings(false); + } +} + +void AFlutterBridge::ApplyQualitySettingsBP(int32 QualityLevel) +{ + ApplyQualitySettings(QualityLevel, -1, -1, -1, -1, -1, -1, -1); +} + +TMap AFlutterBridge::GetQualitySettings() +{ + TMap Settings; + + Settings.Add(TEXT("antiAliasing"), GetAntiAliasingQuality()); + Settings.Add(TEXT("shadow"), GetShadowQuality()); + Settings.Add(TEXT("postProcess"), GetPostProcessQuality()); + Settings.Add(TEXT("texture"), GetTextureQuality()); + Settings.Add(TEXT("effects"), GetEffectsQuality()); + Settings.Add(TEXT("foliage"), GetFoliageQuality()); + Settings.Add(TEXT("viewDistance"), GetViewDistanceQuality()); + + return Settings; +} + +TMap AFlutterBridge::GetQualitySettingsBP() +{ + return GetQualitySettings(); +} + +// ============================================================ +// MARK: - Level Loading +// ============================================================ + +void AFlutterBridge::LoadLevel(const FString& LevelName) +{ + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] Loading level: %s"), *LevelName); + + CurrentLevelName = LevelName; + + if (UWorld* World = GetWorld()) + { + UGameplayStatics::OpenLevel(World, FName(*LevelName)); + } +} + +void AFlutterBridge::LoadLevelBP(const FString& LevelName) +{ + LoadLevel(LevelName); +} + +void AFlutterBridge::OnLevelLoaded() +{ + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] Level loaded: %s"), *CurrentLevelName); + + // Notify Flutter + SendToFlutter(TEXT("FlutterBridge"), TEXT("onLevelLoaded"), CurrentLevelName); + + // Fire Blueprint event + OnLevelLoadedBP(CurrentLevelName); +} + +// ============================================================ +// MARK: - Lifecycle Events +// ============================================================ + +void AFlutterBridge::OnEnginePause() +{ + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] Engine paused")); + bIsPaused = true; + OnEnginePausedBP(); +} + +void AFlutterBridge::OnEngineResume() +{ + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] Engine resumed")); + bIsPaused = false; + OnEngineResumedBP(); +} + +void AFlutterBridge::OnEngineQuit() +{ + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] Engine quitting")); + OnEngineQuitBP(); +} + +// ============================================================ +// MARK: - Surface Events (Android) +// ============================================================ + +void AFlutterBridge::OnSurfaceReady(int32 Width, int32 Height) +{ + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] Surface ready: %dx%d"), Width, Height); + + bSurfaceReady = true; + SurfaceWidth = Width; + SurfaceHeight = Height; + + // TODO: Configure Unreal rendering to use this surface + // This is where you would: + // 1. Create a custom viewport + // 2. Set up render target + // 3. Configure the rendering pipeline + + // Notify Flutter that surface is ready + SendToFlutter(TEXT("FlutterBridge"), TEXT("onSurfaceReady"), FString::Printf(TEXT("{\"width\":%d,\"height\":%d}"), Width, Height)); +} + +void AFlutterBridge::OnSurfaceSizeChanged(int32 Width, int32 Height) +{ + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] Surface size changed: %dx%d"), Width, Height); + + SurfaceWidth = Width; + SurfaceHeight = Height; + + // TODO: Update viewport/render target dimensions + + // Notify Flutter of size change + SendToFlutter(TEXT("FlutterBridge"), TEXT("onSurfaceSizeChanged"), FString::Printf(TEXT("{\"width\":%d,\"height\":%d}"), Width, Height)); +} + +void AFlutterBridge::OnSurfaceDestroyed() +{ + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] Surface destroyed")); + + bSurfaceReady = false; + SurfaceWidth = 0; + SurfaceHeight = 0; + + // TODO: Clean up viewport/render target + + // Notify Flutter + SendToFlutter(TEXT("FlutterBridge"), TEXT("onSurfaceDestroyed"), TEXT("{}")); +} + +void AFlutterBridge::GetSurfaceSize(int32& OutWidth, int32& OutHeight) const +{ + OutWidth = SurfaceWidth; + OutHeight = SurfaceHeight; +} + +bool AFlutterBridge::IsSurfaceReady() const +{ + return bSurfaceReady; +} + +// ============================================================ +// MARK: - Platform Bridge Initialization +// ============================================================ + +void AFlutterBridge::InitializePlatformBridge() +{ +#if PLATFORM_ANDROID + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] Initializing Android bridge")); + extern void FlutterBridge_SetInstance_Android(AFlutterBridge* Instance); + FlutterBridge_SetInstance_Android(this); +#elif PLATFORM_IOS + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] Initializing iOS bridge")); + extern void FlutterBridge_SetInstance_iOS(AFlutterBridge* Instance); + FlutterBridge_SetInstance_iOS(this); +#elif PLATFORM_MAC + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] Initializing macOS bridge")); + extern void FlutterBridge_SetInstance_Mac(AFlutterBridge* Instance); + FlutterBridge_SetInstance_Mac(this); +#else + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] No platform bridge available")); +#endif +} + +// ============================================================ +// MARK: - Quality Settings Helpers +// ============================================================ + +void AFlutterBridge::SetScalabilityQuality(int32 Level) +{ + Scalability::FQualityLevels QualityLevels = Scalability::GetQualityLevels(); + QualityLevels.SetFromSingleQualityLevel(Level); + Scalability::SetQualityLevels(QualityLevels); +} + +void AFlutterBridge::SetAntiAliasingQuality(int32 Quality) +{ + Scalability::FQualityLevels QualityLevels = Scalability::GetQualityLevels(); + QualityLevels.AntiAliasingQuality = Quality; + Scalability::SetQualityLevels(QualityLevels); +} + +void AFlutterBridge::SetShadowQuality(int32 Quality) +{ + Scalability::FQualityLevels QualityLevels = Scalability::GetQualityLevels(); + QualityLevels.ShadowQuality = Quality; + Scalability::SetQualityLevels(QualityLevels); +} + +void AFlutterBridge::SetPostProcessQuality(int32 Quality) +{ + Scalability::FQualityLevels QualityLevels = Scalability::GetQualityLevels(); + QualityLevels.PostProcessQuality = Quality; + Scalability::SetQualityLevels(QualityLevels); +} + +void AFlutterBridge::SetTextureQuality(int32 Quality) +{ + Scalability::FQualityLevels QualityLevels = Scalability::GetQualityLevels(); + QualityLevels.TextureQuality = Quality; + Scalability::SetQualityLevels(QualityLevels); +} + +void AFlutterBridge::SetEffectsQuality(int32 Quality) +{ + Scalability::FQualityLevels QualityLevels = Scalability::GetQualityLevels(); + QualityLevels.EffectsQuality = Quality; + Scalability::SetQualityLevels(QualityLevels); +} + +void AFlutterBridge::SetFoliageQuality(int32 Quality) +{ + Scalability::FQualityLevels QualityLevels = Scalability::GetQualityLevels(); + QualityLevels.FoliageQuality = Quality; + Scalability::SetQualityLevels(QualityLevels); +} + +void AFlutterBridge::SetViewDistanceQuality(int32 Quality) +{ + Scalability::FQualityLevels QualityLevels = Scalability::GetQualityLevels(); + QualityLevels.ViewDistanceQuality = Quality; + Scalability::SetQualityLevels(QualityLevels); +} + +int32 AFlutterBridge::GetAntiAliasingQuality() const +{ + return Scalability::GetQualityLevels().AntiAliasingQuality; +} + +int32 AFlutterBridge::GetShadowQuality() const +{ + return Scalability::GetQualityLevels().ShadowQuality; +} + +int32 AFlutterBridge::GetPostProcessQuality() const +{ + return Scalability::GetQualityLevels().PostProcessQuality; +} + +int32 AFlutterBridge::GetTextureQuality() const +{ + return Scalability::GetQualityLevels().TextureQuality; +} + +int32 AFlutterBridge::GetEffectsQuality() const +{ + return Scalability::GetQualityLevels().EffectsQuality; +} + +int32 AFlutterBridge::GetFoliageQuality() const +{ + return Scalability::GetQualityLevels().FoliageQuality; +} + +int32 AFlutterBridge::GetViewDistanceQuality() const +{ + return Scalability::GetQualityLevels().ViewDistanceQuality; +} diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp new file mode 100644 index 0000000..f582d95 --- /dev/null +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp @@ -0,0 +1,441 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include "FlutterBridge.h" + +#if PLATFORM_IOS || PLATFORM_MAC + +#include "UnrealBridge.h" +#include "Async/Async.h" + +#include + +// ============================================================ +// MARK: - Bridge State +// ============================================================ +// +// Shared by iOS and Mac. The two platforms differ only in the names +// AFlutterBridge dispatches to, so the implementation lives here once and the +// platform entry points at the bottom are shims. +// +// The host app links this framework and registers C callbacks. Nothing here +// touches Objective-C, UIKit or AppKit: the boundary is a flat C ABI so the app +// never needs Unreal headers, include paths or symbols of its own. +// +// Two threads are in play. Unreal calls into SendToFlutter from the game +// thread; the app calls the UnrealBridge_* entry points from its main thread. +// Callback pointers are therefore read and written atomically, and every call +// that touches UObjects is marshalled onto the game thread before it runs. + +static std::atomic GFlutterBridgeInstance{nullptr}; + +static std::atomic GMessageCallback{nullptr}; +static std::atomic GBinaryCallback{nullptr}; + +/// Cached quality settings, refreshed on the game thread. +/// +/// UnrealBridge_GetQualitySettings is synchronous and can be called from the +/// app's main thread, where blocking on the game thread risks deadlock against +/// a game thread already waiting on the main thread. So the getter serves this +/// cache and schedules a refresh for next time. The lock is only ever held for +/// a memcpy-sized copy, so neither thread stalls on it. +static FCriticalSection GQualityCacheLock; +static int32 GCachedQuality[UNREALBRIDGE_QUALITY_VALUE_COUNT] = {0}; +static bool GQualityCacheValid = false; + +/// Order must match the documented layout in UnrealBridge.h. +static const TCHAR* const GQualityKeys[UNREALBRIDGE_QUALITY_VALUE_COUNT] = { + TEXT("antiAliasing"), + TEXT("shadow"), + TEXT("postProcess"), + TEXT("texture"), + TEXT("effects"), + TEXT("foliage"), + TEXT("viewDistance") +}; + +// ============================================================ +// MARK: - Helpers +// ============================================================ + +/// Convert an incoming C string to FString, tolerating null. +static FString CStringToFString(const char* String) +{ + return String ? FString(UTF8_TO_TCHAR(String)) : FString(); +} + +/// Run work on the game thread, immediately if already there. +/// +/// Everything below reaches into UObjects, which is only legal on the game +/// thread. Calls arriving from the app's main thread get queued. +static void RunOnGameThread(TFunction Work) +{ + // Nothing to deliver to, and more importantly nothing safe to ask. Until an + // AFlutterBridge has registered, the engine may not be initialised at all: + // the host can load this library and call in before PreInit has run, and + // IsInGameThread and the task graph both read globals that do not exist + // yet. Dropping the call here is what the callee would have done anyway. + if (GFlutterBridgeInstance.load(std::memory_order_acquire) == nullptr) + { + return; + } + + if (IsInGameThread()) + { + Work(); + } + else + { + AsyncTask(ENamedThreads::GameThread, MoveTemp(Work)); + } +} + +/// Refresh the quality cache. Game thread only. +static void RefreshQualityCache() +{ + check(IsInGameThread()); + + AFlutterBridge* Bridge = GFlutterBridgeInstance.load(std::memory_order_acquire); + if (!Bridge) + { + return; + } + + const TMap Settings = Bridge->GetQualitySettings(); + + FScopeLock Lock(&GQualityCacheLock); + for (int32 Index = 0; Index < UNREALBRIDGE_QUALITY_VALUE_COUNT; ++Index) + { + const int32* Value = Settings.Find(GQualityKeys[Index]); + GCachedQuality[Index] = Value ? *Value : -1; + } + + GQualityCacheValid = true; +} + +// ============================================================ +// MARK: - Unreal to Flutter +// ============================================================ + +/** + * Send a message to Flutter. + * Called from AFlutterBridge::SendToFlutter() on the game thread. + * + * The callback receives pointers into a temporary UTF-8 conversion, so the + * host must copy anything it intends to keep. This is documented on the + * typedef in UnrealBridge.h. + */ +static void SendToFlutter_Apple(const FString& Target, const FString& Method, const FString& Data) +{ + const UnrealMessageCallback Callback = GMessageCallback.load(std::memory_order_acquire); + if (!Callback) + { + UE_LOG(LogTemp, Verbose, + TEXT("[FlutterBridge_Apple] Dropping message, no callback registered: Target=%s, Method=%s"), + *Target, *Method); + return; + } + + const FTCHARToUTF8 TargetUtf8(*Target); + const FTCHARToUTF8 MethodUtf8(*Method); + const FTCHARToUTF8 DataUtf8(*Data); + + Callback(TargetUtf8.Get(), MethodUtf8.Get(), DataUtf8.Get()); +} + +/** + * Send binary data to Flutter. + * Called from AFlutterBridge::SendBinaryToFlutter() on the game thread. + */ +static void SendBinaryToFlutter_Apple(const FString& Target, const FString& Method, const TArray& Data, int32 Checksum) +{ + const UnrealBinaryCallback Callback = GBinaryCallback.load(std::memory_order_acquire); + if (!Callback) + { + UE_LOG(LogTemp, Verbose, + TEXT("[FlutterBridge_Apple] Dropping binary payload, no callback registered: Target=%s, Method=%s, Size=%d"), + *Target, *Method, Data.Num()); + return; + } + + const FTCHARToUTF8 TargetUtf8(*Target); + const FTCHARToUTF8 MethodUtf8(*Method); + + Callback(TargetUtf8.Get(), MethodUtf8.Get(), Data.GetData(), Data.Num(), Checksum); +} + +// ============================================================ +// MARK: - Instance Registration +// ============================================================ + +/** + * Set the FlutterBridge instance. + * Called from AFlutterBridge::BeginPlay() on the game thread. + */ +static void SetInstance_Apple(AFlutterBridge* Instance) +{ + GFlutterBridgeInstance.store(Instance, std::memory_order_release); + + if (Instance) + { + RefreshQualityCache(); + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Apple] FlutterBridge instance set")); + } + else + { + FScopeLock Lock(&GQualityCacheLock); + GQualityCacheValid = false; + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Apple] FlutterBridge instance cleared")); + } +} + +// ============================================================ +// MARK: - Platform Entry Points +// ============================================================ +// +// AFlutterBridge dispatches to a differently named function per platform. iOS +// and Mac share everything above, so these are shims. UnrealBuildTool compiles +// exactly one branch. + +#if PLATFORM_IOS + +void FlutterBridge_SendToFlutter_iOS(const FString& Target, const FString& Method, const FString& Data) +{ + SendToFlutter_Apple(Target, Method, Data); +} + +void FlutterBridge_SendBinaryToFlutter_iOS(const FString& Target, const FString& Method, const TArray& Data, int32 Checksum) +{ + SendBinaryToFlutter_Apple(Target, Method, Data, Checksum); +} + +void FlutterBridge_SetInstance_iOS(AFlutterBridge* Instance) +{ + SetInstance_Apple(Instance); +} + +AFlutterBridge* FlutterBridge_GetInstance_iOS() +{ + return GFlutterBridgeInstance.load(std::memory_order_acquire); +} + +#elif PLATFORM_MAC + +void FlutterBridge_SendToFlutter_Mac(const FString& Target, const FString& Method, const FString& Data) +{ + SendToFlutter_Apple(Target, Method, Data); +} + +void FlutterBridge_SendBinaryToFlutter_Mac(const FString& Target, const FString& Method, const TArray& Data, int32 Checksum) +{ + SendBinaryToFlutter_Apple(Target, Method, Data, Checksum); +} + +void FlutterBridge_SetInstance_Mac(AFlutterBridge* Instance) +{ + SetInstance_Apple(Instance); +} + +AFlutterBridge* FlutterBridge_GetInstance_Mac() +{ + return GFlutterBridgeInstance.load(std::memory_order_acquire); +} + +#endif + +// ============================================================ +// MARK: - C ABI (called by the host app) +// ============================================================ + +extern "C" { + +void UnrealBridge_SetMessageCallback(UnrealMessageCallback Callback) +{ + GMessageCallback.store(Callback, std::memory_order_release); + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Apple] Message callback %s"), + Callback ? TEXT("registered") : TEXT("cleared")); +} + +void UnrealBridge_SetBinaryCallback(UnrealBinaryCallback Callback) +{ + GBinaryCallback.store(Callback, std::memory_order_release); + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Apple] Binary callback %s"), + Callback ? TEXT("registered") : TEXT("cleared")); +} + +void UnrealBridge_SendToUnreal(const char* Target, const char* Method, const char* Data) +{ + const FString TargetString = CStringToFString(Target); + const FString MethodString = CStringToFString(Method); + const FString DataString = CStringToFString(Data); + + RunOnGameThread([TargetString, MethodString, DataString]() + { + if (AFlutterBridge* Bridge = GFlutterBridgeInstance.load(std::memory_order_acquire)) + { + Bridge->ReceiveFromFlutter(TargetString, MethodString, DataString); + } + else + { + UE_LOG(LogTemp, Warning, + TEXT("[FlutterBridge_Apple] Dropping message from Flutter, no bridge actor: Target=%s, Method=%s"), + *TargetString, *MethodString); + } + }); +} + +void UnrealBridge_SendBinaryToUnreal(const char* Target, const char* Method, const void* Data, int32_t Length, int32_t Checksum) +{ + const FString TargetString = CStringToFString(Target); + const FString MethodString = CStringToFString(Method); + + // Copy now. The caller owns its buffer and is free to release it as soon as + // this returns, but the work below runs later on the game thread. + TArray Payload; + if (Data && Length > 0) + { + Payload.Append(static_cast(Data), Length); + } + + RunOnGameThread([TargetString, MethodString, Payload = MoveTemp(Payload), Checksum]() + { + if (AFlutterBridge* Bridge = GFlutterBridgeInstance.load(std::memory_order_acquire)) + { + Bridge->ReceiveBinaryFromFlutter(TargetString, MethodString, Payload, Checksum); + } + else + { + UE_LOG(LogTemp, Warning, + TEXT("[FlutterBridge_Apple] Dropping binary from Flutter, no bridge actor: Target=%s, Method=%s, Size=%d"), + *TargetString, *MethodString, Payload.Num()); + } + }); +} + +void UnrealBridge_ExecuteConsoleCommand(const char* Command) +{ + const FString CommandString = CStringToFString(Command); + + RunOnGameThread([CommandString]() + { + if (AFlutterBridge* Bridge = GFlutterBridgeInstance.load(std::memory_order_acquire)) + { + Bridge->ExecuteConsoleCommand(CommandString); + } + }); +} + +void UnrealBridge_LoadLevel(const char* LevelName) +{ + const FString LevelString = CStringToFString(LevelName); + + RunOnGameThread([LevelString]() + { + if (AFlutterBridge* Bridge = GFlutterBridgeInstance.load(std::memory_order_acquire)) + { + Bridge->LoadLevel(LevelString); + } + }); +} + +void UnrealBridge_ApplyQualitySettings( + int32_t QualityLevel, + int32_t AntiAliasing, + int32_t Shadow, + int32_t PostProcess, + int32_t Texture, + int32_t Effects, + int32_t Foliage, + int32_t ViewDistance) +{ + RunOnGameThread([=]() + { + AFlutterBridge* Bridge = GFlutterBridgeInstance.load(std::memory_order_acquire); + if (!Bridge) + { + return; + } + + Bridge->ApplyQualitySettings( + QualityLevel, AntiAliasing, Shadow, PostProcess, + Texture, Effects, Foliage, ViewDistance); + + RefreshQualityCache(); + }); +} + +int32_t UnrealBridge_GetQualitySettings(int32_t* OutValues, int32_t Capacity) +{ + // Schedule a refresh regardless, so a later call sees current values. + RunOnGameThread([]() + { + RefreshQualityCache(); + }); + + if (!OutValues || Capacity < UNREALBRIDGE_QUALITY_VALUE_COUNT) + { + return 0; + } + + FScopeLock Lock(&GQualityCacheLock); + if (!GQualityCacheValid) + { + return 0; + } + + for (int32 Index = 0; Index < UNREALBRIDGE_QUALITY_VALUE_COUNT; ++Index) + { + OutValues[Index] = GCachedQuality[Index]; + } + + return UNREALBRIDGE_QUALITY_VALUE_COUNT; +} + +void UnrealBridge_Pause(int32_t Paused) +{ + const bool bPaused = Paused != 0; + + RunOnGameThread([bPaused]() + { + AFlutterBridge* Bridge = GFlutterBridgeInstance.load(std::memory_order_acquire); + if (!Bridge) + { + return; + } + + if (bPaused) + { + Bridge->OnEnginePause(); + } + else + { + Bridge->OnEngineResume(); + } + }); +} + +void UnrealBridge_Stop(void) +{ + // Clear callbacks first. The host is going away, and the quit path below + // can still produce messages we would otherwise hand to a dead callback. + GMessageCallback.store(nullptr, std::memory_order_release); + GBinaryCallback.store(nullptr, std::memory_order_release); + + RunOnGameThread([]() + { + if (AFlutterBridge* Bridge = GFlutterBridgeInstance.load(std::memory_order_acquire)) + { + Bridge->OnEngineQuit(); + } + }); + + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Apple] Bridge stopped")); +} + +int32_t UnrealBridge_IsReady(void) +{ + return GFlutterBridgeInstance.load(std::memory_order_acquire) != nullptr ? 1 : 0; +} + +} // extern "C" + +#endif // PLATFORM_IOS || PLATFORM_MAC diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterMessageRouter.cpp b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterMessageRouter.cpp new file mode 100644 index 0000000..4b3745a --- /dev/null +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterMessageRouter.cpp @@ -0,0 +1,395 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include "FlutterMessageRouter.h" +#include "Engine/World.h" +#include "Engine/Engine.h" + +// Initialize static instance +UFlutterMessageRouter* UFlutterMessageRouter::Instance = nullptr; + +UFlutterMessageRouter::UFlutterMessageRouter() + : bQueueUnknownTargets(true) + , MaxQueueSize(1000) +{ +} + +// ============================================================ +// MARK: - Singleton Access +// ============================================================ + +UFlutterMessageRouter* UFlutterMessageRouter::Get(const UObject* WorldContextObject) +{ + if (!Instance) + { + Instance = NewObject(); + Instance->AddToRoot(); // Prevent garbage collection + } + + return Instance; +} + +// ============================================================ +// MARK: - Target Registration +// ============================================================ + +void UFlutterMessageRouter::RegisterTarget(const FString& Name, UObject* Target, bool bIsSingleton) +{ + if (!Target) + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterRouter] Cannot register null target: %s"), *Name); + return; + } + + // Check if singleton already registered + if (bIsSingleton && Targets.Contains(Name)) + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterRouter] Singleton target already registered: %s"), *Name); + return; + } + + Targets.Add(Name, Target); + SingletonFlags.Add(Name, bIsSingleton); + + UE_LOG(LogTemp, Log, TEXT("[FlutterRouter] Registered target: %s (Singleton=%d)"), *Name, bIsSingleton); + + // Update statistics + Statistics.RegisteredTargets = Targets.Num(); + + // Flush any queued messages for this target + FlushQueue(); +} + +void UFlutterMessageRouter::UnregisterTarget(const FString& Name) +{ + if (Targets.Remove(Name) > 0) + { + SingletonFlags.Remove(Name); + + // Remove cached delegates for this target + TArray KeysToRemove; + for (const auto& Pair : CachedDelegates) + { + if (Pair.Key.StartsWith(Name + TEXT(":"))) + { + KeysToRemove.Add(Pair.Key); + } + } + + for (const FString& Key : KeysToRemove) + { + CachedDelegates.Remove(Key); + } + + // Same for binary delegates + KeysToRemove.Empty(); + for (const auto& Pair : CachedBinaryDelegates) + { + if (Pair.Key.StartsWith(Name + TEXT(":"))) + { + KeysToRemove.Add(Pair.Key); + } + } + + for (const FString& Key : KeysToRemove) + { + CachedBinaryDelegates.Remove(Key); + } + + UE_LOG(LogTemp, Log, TEXT("[FlutterRouter] Unregistered target: %s"), *Name); + + // Update statistics + Statistics.RegisteredTargets = Targets.Num(); + Statistics.CachedDelegates = CachedDelegates.Num() + CachedBinaryDelegates.Num(); + } +} + +bool UFlutterMessageRouter::IsTargetRegistered(const FString& Name) const +{ + return Targets.Contains(Name); +} + +TArray UFlutterMessageRouter::GetRegisteredTargets() const +{ + TArray Result; + + for (const auto& Pair : Targets) + { + FFlutterTargetInfo Info; + Info.TargetName = Pair.Key; + Info.TargetObject = Pair.Value; + Info.bIsSingleton = SingletonFlags.Contains(Pair.Key) ? SingletonFlags[Pair.Key] : false; + + // Count registered methods + int32 MethodCount = 0; + for (const auto& DelegatePair : CachedDelegates) + { + if (DelegatePair.Key.StartsWith(Pair.Key + TEXT(":"))) + { + MethodCount++; + } + } + Info.RegisteredMethods = MethodCount; + + Result.Add(Info); + } + + return Result; +} + +// ============================================================ +// MARK: - Method Registration +// ============================================================ + +void UFlutterMessageRouter::RegisterMethod(const FString& TargetName, const FString& MethodName, FFlutterMethodDelegate Delegate) +{ + FString CacheKey = GetCacheKey(TargetName, MethodName); + CachedDelegates.Add(CacheKey, Delegate); + + UE_LOG(LogTemp, Log, TEXT("[FlutterRouter] Registered method: %s"), *CacheKey); + + Statistics.CachedDelegates = CachedDelegates.Num() + CachedBinaryDelegates.Num(); +} + +void UFlutterMessageRouter::RegisterBinaryMethod(const FString& TargetName, const FString& MethodName, FFlutterBinaryMethodDelegate Delegate) +{ + FString CacheKey = GetCacheKey(TargetName, MethodName); + CachedBinaryDelegates.Add(CacheKey, Delegate); + + UE_LOG(LogTemp, Log, TEXT("[FlutterRouter] Registered binary method: %s"), *CacheKey); + + Statistics.CachedDelegates = CachedDelegates.Num() + CachedBinaryDelegates.Num(); +} + +void UFlutterMessageRouter::UnregisterMethod(const FString& TargetName, const FString& MethodName) +{ + FString CacheKey = GetCacheKey(TargetName, MethodName); + CachedDelegates.Remove(CacheKey); + CachedBinaryDelegates.Remove(CacheKey); + + Statistics.CachedDelegates = CachedDelegates.Num() + CachedBinaryDelegates.Num(); +} + +// ============================================================ +// MARK: - Message Routing +// ============================================================ + +bool UFlutterMessageRouter::RouteMessage(const FString& Target, const FString& Method, const FString& Data) +{ + FString CacheKey = GetCacheKey(Target, Method); + + // Try cached delegate first (zero-reflection fast path) + if (TryRouteCached(CacheKey, Method, Data)) + { + Statistics.MessagesRouted++; + return true; + } + + // Check if target is registered but method is not + if (Targets.Contains(Target)) + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterRouter] No handler for method: %s on target: %s"), *Method, *Target); + Statistics.MessagesDropped++; + return false; + } + + // Target not registered - queue if enabled + if (bQueueUnknownTargets) + { + QueueMessage(Target, Method, Data); + return true; + } + + UE_LOG(LogTemp, Warning, TEXT("[FlutterRouter] Unknown target: %s"), *Target); + Statistics.MessagesDropped++; + return false; +} + +bool UFlutterMessageRouter::RouteBinaryMessage(const FString& Target, const FString& Method, const TArray& Data) +{ + FString CacheKey = GetCacheKey(Target, Method); + + // Try cached delegate first + if (TryRouteBinaryCached(CacheKey, Method, Data)) + { + Statistics.MessagesRouted++; + return true; + } + + // Check if target is registered but method is not + if (Targets.Contains(Target)) + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterRouter] No binary handler for method: %s on target: %s"), *Method, *Target); + Statistics.MessagesDropped++; + return false; + } + + // Target not registered - queue if enabled + if (bQueueUnknownTargets) + { + FQueuedFlutterMessage QueuedMsg; + QueuedMsg.Target = Target; + QueuedMsg.Method = Method; + QueuedMsg.bIsBinary = true; + QueuedMsg.BinaryData = Data; + + if (MessageQueue.Num() < MaxQueueSize) + { + MessageQueue.Add(QueuedMsg); + Statistics.QueuedMessages = MessageQueue.Num(); + } + else + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterRouter] Message queue full, dropping message")); + Statistics.MessagesDropped++; + } + return true; + } + + UE_LOG(LogTemp, Warning, TEXT("[FlutterRouter] Unknown target: %s"), *Target); + Statistics.MessagesDropped++; + return false; +} + +bool UFlutterMessageRouter::TryRouteCached(const FString& CacheKey, const FString& Method, const FString& Data) +{ + FFlutterMethodDelegate* Delegate = CachedDelegates.Find(CacheKey); + if (Delegate && Delegate->IsBound()) + { + Delegate->Execute(Method, Data); + return true; + } + return false; +} + +bool UFlutterMessageRouter::TryRouteBinaryCached(const FString& CacheKey, const FString& Method, const TArray& Data) +{ + FFlutterBinaryMethodDelegate* Delegate = CachedBinaryDelegates.Find(CacheKey); + if (Delegate && Delegate->IsBound()) + { + Delegate->Execute(Method, Data); + return true; + } + return false; +} + +// ============================================================ +// MARK: - Message Queuing +// ============================================================ + +void UFlutterMessageRouter::QueueMessage(const FString& Target, const FString& Method, const FString& Data) +{ + if (MessageQueue.Num() >= MaxQueueSize) + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterRouter] Message queue full, dropping oldest message")); + MessageQueue.RemoveAt(0); + } + + FQueuedFlutterMessage QueuedMsg; + QueuedMsg.Target = Target; + QueuedMsg.Method = Method; + QueuedMsg.Data = Data; + QueuedMsg.bIsBinary = false; + + MessageQueue.Add(QueuedMsg); + Statistics.QueuedMessages = MessageQueue.Num(); + + UE_LOG(LogTemp, Verbose, TEXT("[FlutterRouter] Queued message for target: %s"), *Target); +} + +void UFlutterMessageRouter::FlushQueue() +{ + if (MessageQueue.Num() == 0) + { + return; + } + + TArray MessagesToProcess = MessageQueue; + MessageQueue.Empty(); + + for (const FQueuedFlutterMessage& Msg : MessagesToProcess) + { + if (Msg.bIsBinary) + { + if (!RouteBinaryMessage(Msg.Target, Msg.Method, Msg.BinaryData)) + { + // Re-queue if still no handler + if (bQueueUnknownTargets && !Targets.Contains(Msg.Target)) + { + MessageQueue.Add(Msg); + } + } + } + else + { + if (!RouteMessage(Msg.Target, Msg.Method, Msg.Data)) + { + // Re-queue if still no handler + if (bQueueUnknownTargets && !Targets.Contains(Msg.Target)) + { + MessageQueue.Add(Msg); + } + } + } + } + + Statistics.QueuedMessages = MessageQueue.Num(); +} + +void UFlutterMessageRouter::ClearQueue() +{ + int32 Cleared = MessageQueue.Num(); + MessageQueue.Empty(); + Statistics.QueuedMessages = 0; + + UE_LOG(LogTemp, Log, TEXT("[FlutterRouter] Cleared %d queued messages"), Cleared); +} + +// ============================================================ +// MARK: - Statistics +// ============================================================ + +FFlutterRouterStatistics UFlutterMessageRouter::GetStatistics() const +{ + return Statistics; +} + +void UFlutterMessageRouter::ResetStatistics() +{ + Statistics.MessagesRouted = 0; + Statistics.MessagesDropped = 0; + // Keep registration counts accurate + Statistics.RegisteredTargets = Targets.Num(); + Statistics.CachedDelegates = CachedDelegates.Num() + CachedBinaryDelegates.Num(); + Statistics.QueuedMessages = MessageQueue.Num(); +} + +// ============================================================ +// MARK: - Configuration +// ============================================================ + +void UFlutterMessageRouter::SetQueueUnknownTargets(bool bEnable) +{ + bQueueUnknownTargets = bEnable; +} + +void UFlutterMessageRouter::SetMaxQueueSize(int32 Size) +{ + MaxQueueSize = FMath::Max(1, Size); + + // Trim queue if necessary + while (MessageQueue.Num() > MaxQueueSize) + { + MessageQueue.RemoveAt(0); + } + + Statistics.QueuedMessages = MessageQueue.Num(); +} + +// ============================================================ +// MARK: - Helpers +// ============================================================ + +FString UFlutterMessageRouter::GetCacheKey(const FString& Target, const FString& Method) const +{ + return FString::Printf(TEXT("%s:%s"), *Target, *Method); +} diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterPlugin.cpp b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterPlugin.cpp new file mode 100644 index 0000000..c0fa87c --- /dev/null +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterPlugin.cpp @@ -0,0 +1,21 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include "FlutterPlugin.h" + +#define LOCTEXT_NAMESPACE "FFlutterPluginModule" + +void FFlutterPluginModule::StartupModule() +{ + // This code will execute after your module is loaded into memory + UE_LOG(LogTemp, Log, TEXT("FlutterPlugin module started")); +} + +void FFlutterPluginModule::ShutdownModule() +{ + // This function may be called during shutdown to clean up your module + UE_LOG(LogTemp, Log, TEXT("FlutterPlugin module shutdown")); +} + +#undef LOCTEXT_NAMESPACE + +IMPLEMENT_MODULE(FFlutterPluginModule, FlutterPlugin) diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/FlutterAssetManager.h b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/FlutterAssetManager.h new file mode 100644 index 0000000..c924230 --- /dev/null +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/FlutterAssetManager.h @@ -0,0 +1,280 @@ +#pragma once + +#include "CoreMinimal.h" +#include "Engine/StreamableManager.h" +#include "FlutterAssetManager.generated.h" + +/** + * Asset loading state enumeration + */ +UENUM(BlueprintType) +enum class EFlutterAssetState : uint8 +{ + NotLoaded, + Loading, + Loaded, + Failed, + Unloading +}; + +/** + * Information about a loaded asset + */ +USTRUCT(BlueprintType) +struct FFlutterLoadedAsset +{ + GENERATED_BODY() + + UPROPERTY(BlueprintReadOnly, Category = "Flutter|Assets") + FString AssetPath; + + UPROPERTY(BlueprintReadOnly, Category = "Flutter|Assets") + EFlutterAssetState State; + + UPROPERTY(BlueprintReadOnly, Category = "Flutter|Assets") + UObject* Asset = nullptr; + + UPROPERTY(BlueprintReadOnly, Category = "Flutter|Assets") + int64 LoadTimeMs = 0; + + UPROPERTY(BlueprintReadOnly, Category = "Flutter|Assets") + int32 SizeBytes = 0; +}; + +/** + * Asset loading progress information + */ +USTRUCT(BlueprintType) +struct FFlutterAssetProgress +{ + GENERATED_BODY() + + UPROPERTY(BlueprintReadOnly, Category = "Flutter|Assets") + int32 TotalAssets = 0; + + UPROPERTY(BlueprintReadOnly, Category = "Flutter|Assets") + int32 LoadedAssets = 0; + + UPROPERTY(BlueprintReadOnly, Category = "Flutter|Assets") + int32 FailedAssets = 0; + + UPROPERTY(BlueprintReadOnly, Category = "Flutter|Assets") + float Progress = 0.0f; + + UPROPERTY(BlueprintReadOnly, Category = "Flutter|Assets") + int64 TotalSizeBytes = 0; + + UPROPERTY(BlueprintReadOnly, Category = "Flutter|Assets") + int64 LoadedSizeBytes = 0; +}; + +/** + * Asset manager statistics + */ +USTRUCT(BlueprintType) +struct FFlutterAssetStatistics +{ + GENERATED_BODY() + + UPROPERTY(BlueprintReadOnly, Category = "Flutter|Assets") + int32 TotalAssetsLoaded = 0; + + UPROPERTY(BlueprintReadOnly, Category = "Flutter|Assets") + int32 TotalAssetsUnloaded = 0; + + UPROPERTY(BlueprintReadOnly, Category = "Flutter|Assets") + int64 TotalBytesLoaded = 0; + + UPROPERTY(BlueprintReadOnly, Category = "Flutter|Assets") + int64 CurrentMemoryUsage = 0; + + UPROPERTY(BlueprintReadOnly, Category = "Flutter|Assets") + int32 CacheHits = 0; + + UPROPERTY(BlueprintReadOnly, Category = "Flutter|Assets") + int32 CacheMisses = 0; + + UPROPERTY(BlueprintReadOnly, Category = "Flutter|Assets") + float AverageLoadTimeMs = 0.0f; +}; + +// Delegate declarations +DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnFlutterAssetLoaded, const FString&, AssetPath, UObject*, Asset); +DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnFlutterAssetFailed, const FString&, AssetPath, const FString&, ErrorMessage); +DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnFlutterAssetProgress, const FFlutterAssetProgress&, Progress); +DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnFlutterAssetUnloaded, const FString&, AssetPath); + +/** + * Asset manager for Flutter-Unreal integration. + * Provides async asset loading with progress tracking and caching. + */ +UCLASS(BlueprintType, Blueprintable) +class FLUTTERPLUGIN_API UFlutterAssetManager : public UObject +{ + GENERATED_BODY() + +public: + UFlutterAssetManager(); + + /** Get the singleton instance */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Assets", meta = (WorldContext = "WorldContextObject")) + static UFlutterAssetManager* Get(UObject* WorldContextObject); + + // ==================== ASSET LOADING ==================== + + /** Load a single asset asynchronously */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Assets") + void LoadAsset(const FString& AssetPath); + + /** Load a single asset and return it (blocking) */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Assets") + UObject* LoadAssetSync(const FString& AssetPath); + + /** Load multiple assets asynchronously */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Assets") + void LoadAssets(const TArray& AssetPaths); + + /** Load a level by name */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Assets") + void LoadLevel(const FString& LevelName, bool bAbsolute = true); + + /** Load a level asynchronously with streaming */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Assets") + void LoadLevelAsync(const FString& LevelName); + + // ==================== ASSET UNLOADING ==================== + + /** Unload a single asset */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Assets") + void UnloadAsset(const FString& AssetPath); + + /** Unload multiple assets */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Assets") + void UnloadAssets(const TArray& AssetPaths); + + /** Unload all loaded assets */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Assets") + void UnloadAllAssets(); + + /** Unload a streaming level */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Assets") + void UnloadLevel(const FString& LevelName); + + // ==================== ASSET QUERIES ==================== + + /** Check if an asset is loaded */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Assets") + bool IsAssetLoaded(const FString& AssetPath) const; + + /** Get the state of an asset */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Assets") + EFlutterAssetState GetAssetState(const FString& AssetPath) const; + + /** Get a loaded asset */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Assets") + UObject* GetLoadedAsset(const FString& AssetPath) const; + + /** Get information about a loaded asset */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Assets") + FFlutterLoadedAsset GetAssetInfo(const FString& AssetPath) const; + + /** Get all loaded asset paths */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Assets") + TArray GetLoadedAssetPaths() const; + + // ==================== CACHE MANAGEMENT ==================== + + /** Set the maximum cache size in bytes */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Assets") + void SetCacheMaxSize(int64 MaxSizeBytes); + + /** Get the current cache size */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Assets") + int64 GetCacheSize() const; + + /** Clear the asset cache */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Assets") + void ClearCache(); + + /** Trim cache to fit within max size */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Assets") + void TrimCache(); + + // ==================== STATISTICS ==================== + + /** Get asset manager statistics */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Assets") + FFlutterAssetStatistics GetStatistics() const; + + /** Reset statistics */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Assets") + void ResetStatistics(); + + // ==================== FLUTTER COMMUNICATION ==================== + + /** Notify Flutter of asset load progress */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Assets") + void NotifyFlutterProgress(const FFlutterAssetProgress& Progress); + + /** Notify Flutter of asset loaded */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Assets") + void NotifyFlutterAssetLoaded(const FString& AssetPath); + + /** Notify Flutter of asset load failure */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Assets") + void NotifyFlutterAssetFailed(const FString& AssetPath, const FString& ErrorMessage); + + // ==================== EVENTS ==================== + + /** Called when an asset is loaded successfully */ + UPROPERTY(BlueprintAssignable, Category = "Flutter|Assets") + FOnFlutterAssetLoaded OnAssetLoaded; + + /** Called when an asset fails to load */ + UPROPERTY(BlueprintAssignable, Category = "Flutter|Assets") + FOnFlutterAssetFailed OnAssetFailed; + + /** Called when loading progress updates */ + UPROPERTY(BlueprintAssignable, Category = "Flutter|Assets") + FOnFlutterAssetProgress OnProgress; + + /** Called when an asset is unloaded */ + UPROPERTY(BlueprintAssignable, Category = "Flutter|Assets") + FOnFlutterAssetUnloaded OnAssetUnloaded; + +protected: + /** Handle async load completion */ + void HandleAssetLoaded(const FString& AssetPath, UObject* Asset); + + /** Update progress and notify listeners */ + void UpdateProgress(); + + /** Calculate asset size estimate */ + int32 EstimateAssetSize(UObject* Asset) const; + +private: + /** Singleton instance */ + static UFlutterAssetManager* Instance; + + /** Streamable manager for async loading */ + FStreamableManager StreamableManager; + + /** Currently loaded assets */ + UPROPERTY() + TMap LoadedAssets; + + /** Assets currently being loaded */ + TMap> PendingLoads; + + /** Cache settings */ + int64 CacheMaxSizeBytes = 256 * 1024 * 1024; // 256 MB default + + /** Statistics */ + FFlutterAssetStatistics Statistics; + + /** Current batch loading progress */ + FFlutterAssetProgress CurrentProgress; + + /** Batch load asset paths */ + TArray BatchLoadPaths; +}; diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/FlutterBlueprintLibrary.h b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/FlutterBlueprintLibrary.h new file mode 100644 index 0000000..98e3bd0 --- /dev/null +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/FlutterBlueprintLibrary.h @@ -0,0 +1,195 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "Kismet/BlueprintFunctionLibrary.h" +#include "FlutterMessageRouter.h" +#include "FlutterBlueprintLibrary.generated.h" + +/** + * Flutter Blueprint Function Library + * + * Provides Blueprint-accessible functions for Flutter integration. + * Includes utilities for messaging, quality settings, and router configuration. + * + * Usage in Blueprints: + * - Send Flutter Message: Send a message to Flutter + * - Send Flutter Binary: Send binary data to Flutter + * - Register Flutter Target: Register an object to receive Flutter messages + * - Get Flutter Bridge: Get the FlutterBridge actor instance + */ +UCLASS() +class FLUTTERPLUGIN_API UFlutterBlueprintLibrary : public UBlueprintFunctionLibrary +{ + GENERATED_BODY() + +public: + // ============================================================ + // MARK: - Messaging + // ============================================================ + + /** + * Send a message to Flutter + * @param Target - The target object in Flutter (e.g., "GameManager") + * @param Method - The method name to call (e.g., "onGameStateChanged") + * @param Data - The data to send (JSON string) + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Messaging", meta = (WorldContext = "WorldContextObject")) + static void SendFlutterMessage(const UObject* WorldContextObject, const FString& Target, const FString& Method, const FString& Data); + + /** + * Send a JSON object to Flutter + * @param Target - The target object in Flutter + * @param Method - The method name to call + * @param JsonObject - Map of key-value pairs to send as JSON + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Messaging", meta = (WorldContext = "WorldContextObject")) + static void SendFlutterJsonMessage(const UObject* WorldContextObject, const FString& Target, const FString& Method, const TMap& JsonObject); + + /** + * Send binary data to Flutter + * @param Target - The target object in Flutter + * @param Method - The method name to call + * @param Data - The binary data to send + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Messaging", meta = (WorldContext = "WorldContextObject")) + static void SendFlutterBinaryMessage(const UObject* WorldContextObject, const FString& Target, const FString& Method, const TArray& Data); + + // ============================================================ + // MARK: - Router Registration + // ============================================================ + + /** + * Register a target to receive Flutter messages + * @param TargetName - The name to register (e.g., "GameManager") + * @param Target - The object to receive messages + * @param bIsSingleton - If true, only one instance can be registered + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Router", meta = (WorldContext = "WorldContextObject")) + static void RegisterFlutterTarget(const UObject* WorldContextObject, const FString& TargetName, UObject* Target, bool bIsSingleton = true); + + /** + * Unregister a target from receiving Flutter messages + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Router", meta = (WorldContext = "WorldContextObject")) + static void UnregisterFlutterTarget(const UObject* WorldContextObject, const FString& TargetName); + + /** + * Check if a target is registered + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Router", meta = (WorldContext = "WorldContextObject")) + static bool IsFlutterTargetRegistered(const UObject* WorldContextObject, const FString& TargetName); + + /** + * Get all registered Flutter targets + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Router", meta = (WorldContext = "WorldContextObject")) + static TArray GetRegisteredFlutterTargets(const UObject* WorldContextObject); + + /** + * Get router statistics + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Router", meta = (WorldContext = "WorldContextObject")) + static FFlutterRouterStatistics GetFlutterRouterStatistics(const UObject* WorldContextObject); + + // ============================================================ + // MARK: - Quality Settings + // ============================================================ + + /** + * Apply a quality preset + * @param QualityLevel - 0=Low, 1=Medium, 2=High, 3=Epic, 4=Cinematic + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Quality", meta = (WorldContext = "WorldContextObject")) + static void ApplyFlutterQualityPreset(const UObject* WorldContextObject, int32 QualityLevel); + + /** + * Apply custom quality settings + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Quality", meta = (WorldContext = "WorldContextObject")) + static void ApplyFlutterQualitySettings( + const UObject* WorldContextObject, + int32 AntiAliasing, + int32 Shadows, + int32 PostProcess, + int32 Textures, + int32 Effects, + int32 Foliage, + int32 ViewDistance + ); + + /** + * Get current quality settings + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Quality", meta = (WorldContext = "WorldContextObject")) + static TMap GetFlutterQualitySettings(const UObject* WorldContextObject); + + // ============================================================ + // MARK: - Lifecycle + // ============================================================ + + /** + * Request to load a level + * This will notify Flutter and load the level + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Level", meta = (WorldContext = "WorldContextObject")) + static void LoadFlutterLevel(const UObject* WorldContextObject, const FString& LevelName); + + /** + * Execute a console command via Flutter bridge + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Console", meta = (WorldContext = "WorldContextObject")) + static void ExecuteFlutterConsoleCommand(const UObject* WorldContextObject, const FString& Command); + + // ============================================================ + // MARK: - Bridge Access + // ============================================================ + + /** + * Get the Flutter Bridge actor instance + * Returns null if not found + */ + UFUNCTION(BlueprintCallable, Category = "Flutter", meta = (WorldContext = "WorldContextObject")) + static class AFlutterBridge* GetFlutterBridge(const UObject* WorldContextObject); + + /** + * Get the Flutter Message Router instance + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Router", meta = (WorldContext = "WorldContextObject")) + static UFlutterMessageRouter* GetFlutterRouter(const UObject* WorldContextObject); + + /** + * Check if Flutter bridge is available + */ + UFUNCTION(BlueprintCallable, Category = "Flutter", meta = (WorldContext = "WorldContextObject")) + static bool IsFlutterBridgeAvailable(const UObject* WorldContextObject); + + // ============================================================ + // MARK: - Utilities + // ============================================================ + + /** + * Convert a map to JSON string + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Utilities") + static FString MapToJsonString(const TMap& Map); + + /** + * Parse JSON string to map + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Utilities") + static TMap JsonStringToMap(const FString& JsonString); + + /** + * Encode bytes to Base64 string + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Utilities") + static FString EncodeBase64(const TArray& Data); + + /** + * Decode Base64 string to bytes + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Utilities") + static TArray DecodeBase64(const FString& Base64String); +}; diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/FlutterBridge.h b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/FlutterBridge.h new file mode 100644 index 0000000..b74cfb6 --- /dev/null +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/FlutterBridge.h @@ -0,0 +1,379 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "GameFramework/Actor.h" +#include "FlutterBridge.generated.h" + +/** + * Flutter Bridge Actor + * + * Main bridge between Flutter and Unreal Engine. + * Handles bidirectional communication, console commands, quality settings, + * and level loading. + */ +UCLASS(Blueprintable, BlueprintType) +class FLUTTERPLUGIN_API AFlutterBridge : public AActor +{ + GENERATED_BODY() + +public: + AFlutterBridge(); + +protected: + virtual void BeginPlay() override; + virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override; + +public: + virtual void Tick(float DeltaTime) override; + + // ============================================================ + // MARK: - Message Communication + // ============================================================ + + /** + * Send a message to Flutter + * @param Target - The target object in Flutter (e.g., "GameManager") + * @param Method - The method name to call (e.g., "onGameStateChanged") + * @param Data - The data to send (JSON string) + */ + UFUNCTION(BlueprintCallable, Category = "Flutter") + void SendToFlutter(const FString& Target, const FString& Method, const FString& Data); + + /** + * Called when a message is received from Flutter + * This is called from native code (JNI/Objective-C++) + * @param Target - The target object in Unreal (e.g., "PlayerController") + * @param Method - The method name to call + * @param Data - The data received (JSON string) + */ + void ReceiveFromFlutter(const FString& Target, const FString& Method, const FString& Data); + + /** + * Blueprint event fired when a message is received from Flutter + */ + UFUNCTION(BlueprintImplementableEvent, Category = "Flutter") + void OnMessageFromFlutter(const FString& Target, const FString& Method, const FString& Data); + + // ============================================================ + // MARK: - Binary Message Communication + // ============================================================ + + /** + * Send binary data to Flutter + * @param Target - The target object in Flutter + * @param Method - The method name to call + * @param Data - The binary data to send + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Binary") + void SendBinaryToFlutter(const FString& Target, const FString& Method, const TArray& Data); + + /** + * Called when binary data is received from Flutter + * @param Target - The target object in Unreal + * @param Method - The method name to call + * @param Data - The binary data received + * @param Checksum - CRC32 checksum for verification + */ + void ReceiveBinaryFromFlutter(const FString& Target, const FString& Method, const TArray& Data, int32 Checksum); + + /** + * Blueprint event fired when binary data is received from Flutter + */ + UFUNCTION(BlueprintImplementableEvent, Category = "Flutter|Binary") + void OnBinaryMessageFromFlutter(const FString& Target, const FString& Method, const TArray& Data); + + /** + * Called when a binary chunk header is received (start of chunked transfer) + */ + void ReceiveBinaryChunkHeader( + const FString& Target, + const FString& Method, + const FString& TransferId, + int32 TotalSize, + int32 TotalChunks, + int32 Checksum + ); + + /** + * Called when a binary chunk data is received + */ + void ReceiveBinaryChunkData( + const FString& Target, + const FString& Method, + const FString& TransferId, + int32 ChunkIndex, + const TArray& Data + ); + + /** + * Called when a binary chunk footer is received (end of chunked transfer) + */ + void ReceiveBinaryChunkFooter( + const FString& Target, + const FString& Method, + const FString& TransferId, + int32 TotalChunks, + int32 Checksum + ); + + /** + * Set the chunk size for binary transfers + */ + void SetBinaryChunkSize(int32 Size); + + /** + * Get the current chunk size for binary transfers + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Binary") + int32 GetBinaryChunkSize() const; + + /** + * Blueprint event fired when a chunked transfer completes + */ + UFUNCTION(BlueprintImplementableEvent, Category = "Flutter|Binary") + void OnChunkedTransferComplete(const FString& TransferId, const TArray& Data); + + /** + * Blueprint event fired to report binary transfer progress + */ + UFUNCTION(BlueprintImplementableEvent, Category = "Flutter|Binary") + void OnBinaryTransferProgress(const FString& TransferId, int32 CurrentChunk, int32 TotalChunks, float Progress); + + // ============================================================ + // MARK: - Console Commands + // ============================================================ + + /** + * Execute a console command + * Called from Flutter via native bridge + * @param Command - The console command to execute (e.g., "stat fps") + */ + void ExecuteConsoleCommand(const FString& Command); + + /** + * Execute a console command (Blueprint callable) + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Console") + void ExecuteConsoleCommandBP(const FString& Command); + + // ============================================================ + // MARK: - Quality Settings + // ============================================================ + + /** + * Apply quality settings from Flutter + * @param QualityLevel - Overall quality level (0-4: Low, Medium, High, Epic, Cinematic) + * @param AntiAliasing - Anti-aliasing quality (0-4) + * @param Shadow - Shadow quality (0-4) + * @param PostProcess - Post-processing quality (0-4) + * @param Texture - Texture quality (0-4) + * @param Effects - Effects quality (0-4) + * @param Foliage - Foliage quality (0-4) + * @param ViewDistance - View distance quality (0-4) + */ + void ApplyQualitySettings( + int32 QualityLevel, + int32 AntiAliasing, + int32 Shadow, + int32 PostProcess, + int32 Texture, + int32 Effects, + int32 Foliage, + int32 ViewDistance + ); + + /** + * Apply quality settings (Blueprint callable) + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Quality") + void ApplyQualitySettingsBP(int32 QualityLevel); + + /** + * Get current quality settings + * @return Map of quality setting names to values + */ + TMap GetQualitySettings(); + + /** + * Get current quality settings (Blueprint callable) + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Quality") + TMap GetQualitySettingsBP(); + + // ============================================================ + // MARK: - Level Loading + // ============================================================ + + /** + * Load a level/map + * Called from Flutter via native bridge + * @param LevelName - Name of the level to load + */ + void LoadLevel(const FString& LevelName); + + /** + * Load a level (Blueprint callable) + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Level") + void LoadLevelBP(const FString& LevelName); + + /** + * Called when a level has finished loading + */ + UFUNCTION() + void OnLevelLoaded(); + + /** + * Blueprint event fired when a level is loaded + */ + UFUNCTION(BlueprintImplementableEvent, Category = "Flutter|Level") + void OnLevelLoadedBP(const FString& LevelName); + + // ============================================================ + // MARK: - Lifecycle Events + // ============================================================ + + /** + * Called when the engine is paused by Flutter + */ + void OnEnginePause(); + + /** + * Called when the engine is resumed by Flutter + */ + void OnEngineResume(); + + /** + * Called when the engine is being quit by Flutter + */ + void OnEngineQuit(); + + // ============================================================ + // MARK: - Surface Events (Android) + // ============================================================ + + /** + * Called when the rendering surface is ready + * The surface is created by Flutter's SurfaceView and passed to native code + * @param Width - Surface width in pixels + * @param Height - Surface height in pixels + */ + void OnSurfaceReady(int32 Width, int32 Height); + + /** + * Called when the surface dimensions change + * @param Width - New surface width + * @param Height - New surface height + */ + void OnSurfaceSizeChanged(int32 Width, int32 Height); + + /** + * Called when the surface is destroyed + */ + void OnSurfaceDestroyed(); + + /** + * Get the current surface dimensions + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Surface") + void GetSurfaceSize(int32& OutWidth, int32& OutHeight) const; + + /** + * Check if surface is ready for rendering + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Surface") + bool IsSurfaceReady() const; + + /** + * Blueprint events for lifecycle + */ + UFUNCTION(BlueprintImplementableEvent, Category = "Flutter|Lifecycle") + void OnEnginePausedBP(); + + UFUNCTION(BlueprintImplementableEvent, Category = "Flutter|Lifecycle") + void OnEngineResumedBP(); + + UFUNCTION(BlueprintImplementableEvent, Category = "Flutter|Lifecycle") + void OnEngineQuitBP(); + + // ============================================================ + // MARK: - Singleton Access + // ============================================================ + + /** + * Get the global FlutterBridge instance + * Only one instance should exist in the world + */ + UFUNCTION(BlueprintCallable, Category = "Flutter", meta = (WorldContext = "WorldContextObject")) + static AFlutterBridge* GetInstance(const UObject* WorldContextObject); + +private: + // Singleton instance + static AFlutterBridge* Instance; + + // Current level being loaded + FString CurrentLevelName; + + // Is engine paused? + bool bIsPaused; + + // Binary transfer chunk size (default 64KB) + int32 BinaryChunkSize; + + // Surface state (Android) + bool bSurfaceReady; + int32 SurfaceWidth; + int32 SurfaceHeight; + + // Active chunked transfers + struct FChunkedTransfer + { + FString Target; + FString Method; + int32 TotalSize; + int32 TotalChunks; + int32 ExpectedChecksum; + TMap> Chunks; + int32 ReceivedChunks; + + FChunkedTransfer() + : TotalSize(0) + , TotalChunks(0) + , ExpectedChecksum(0) + , ReceivedChunks(0) + {} + }; + + TMap ActiveTransfers; + + // Binary helpers + int32 CalculateCRC32(const TArray& Data) const; + bool VerifyChecksum(const TArray& Data, int32 ExpectedChecksum) const; + TArray CompressData(const TArray& Data) const; + TArray DecompressData(const TArray& Data) const; + void AssembleChunkedTransfer(const FString& TransferId); + + // Platform-specific bridge initialization + void InitializePlatformBridge(); + + // Quality setting helpers + void SetScalabilityQuality(int32 Level); + void SetAntiAliasingQuality(int32 Quality); + void SetShadowQuality(int32 Quality); + void SetPostProcessQuality(int32 Quality); + void SetTextureQuality(int32 Quality); + void SetEffectsQuality(int32 Quality); + void SetFoliageQuality(int32 Quality); + void SetViewDistanceQuality(int32 Quality); + + // Get individual quality settings + int32 GetAntiAliasingQuality() const; + int32 GetShadowQuality() const; + int32 GetPostProcessQuality() const; + int32 GetTextureQuality() const; + int32 GetEffectsQuality() const; + int32 GetFoliageQuality() const; + int32 GetViewDistanceQuality() const; +}; diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/FlutterMessageRouter.h b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/FlutterMessageRouter.h new file mode 100644 index 0000000..dc42957 --- /dev/null +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/FlutterMessageRouter.h @@ -0,0 +1,303 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "UObject/NoExportTypes.h" +#include "FlutterMessageRouter.generated.h" + +// Forward declarations +class AFlutterBridge; + +/** + * Delegate for handling Flutter messages + */ +DECLARE_DYNAMIC_DELEGATE_TwoParams(FFlutterMethodDelegate, const FString&, Method, const FString&, Data); + +/** + * Delegate for handling Flutter binary messages + */ +DECLARE_DYNAMIC_DELEGATE_TwoParams(FFlutterBinaryMethodDelegate, const FString&, Method, const TArray&, Data); + +/** + * Registration info for a Flutter target + */ +USTRUCT(BlueprintType) +struct FFlutterTargetInfo +{ + GENERATED_BODY() + + UPROPERTY(BlueprintReadOnly, Category = "Flutter") + FString TargetName; + + UPROPERTY(BlueprintReadOnly, Category = "Flutter") + UObject* TargetObject; + + UPROPERTY(BlueprintReadOnly, Category = "Flutter") + bool bIsSingleton; + + UPROPERTY(BlueprintReadOnly, Category = "Flutter") + int32 RegisteredMethods; + + FFlutterTargetInfo() + : TargetObject(nullptr) + , bIsSingleton(false) + , RegisteredMethods(0) + {} +}; + +/** + * Statistics for the message router + */ +USTRUCT(BlueprintType) +struct FFlutterRouterStatistics +{ + GENERATED_BODY() + + UPROPERTY(BlueprintReadOnly, Category = "Flutter") + int32 MessagesRouted; + + UPROPERTY(BlueprintReadOnly, Category = "Flutter") + int32 MessagesDropped; + + UPROPERTY(BlueprintReadOnly, Category = "Flutter") + int32 RegisteredTargets; + + UPROPERTY(BlueprintReadOnly, Category = "Flutter") + int32 CachedDelegates; + + UPROPERTY(BlueprintReadOnly, Category = "Flutter") + int32 QueuedMessages; + + FFlutterRouterStatistics() + : MessagesRouted(0) + , MessagesDropped(0) + , RegisteredTargets(0) + , CachedDelegates(0) + , QueuedMessages(0) + {} +}; + +/** + * Queued message for pre-ready delivery + */ +struct FQueuedFlutterMessage +{ + FString Target; + FString Method; + FString Data; + bool bIsBinary; + TArray BinaryData; + + FQueuedFlutterMessage() + : bIsBinary(false) + {} +}; + +/** + * Flutter Message Router + * + * High-performance message router with cached delegates for zero-reflection dispatch. + * Supports singleton and multi-instance targets, attribute-based method registration, + * and pre-ready message queuing. + * + * Usage: + * ```cpp + * // Register a target + * UFlutterMessageRouter* Router = UFlutterMessageRouter::Get(this); + * Router->RegisterTarget("GameManager", this, true); + * + * // Register a method handler + * FFlutterMethodDelegate Delegate; + * Delegate.BindDynamic(this, &AMyActor::OnPlayerAction); + * Router->RegisterMethod("GameManager", "onPlayerAction", Delegate); + * ``` + */ +UCLASS(BlueprintType) +class FLUTTERPLUGIN_API UFlutterMessageRouter : public UObject +{ + GENERATED_BODY() + +public: + UFlutterMessageRouter(); + + // ============================================================ + // MARK: - Singleton Access + // ============================================================ + + /** + * Get the global message router instance + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Router", meta = (WorldContext = "WorldContextObject")) + static UFlutterMessageRouter* Get(const UObject* WorldContextObject); + + // ============================================================ + // MARK: - Target Registration + // ============================================================ + + /** + * Register a target object that can receive Flutter messages + * @param Name - The target name (e.g., "GameManager") + * @param Target - The object to receive messages + * @param bIsSingleton - If true, only one instance can be registered with this name + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Router") + void RegisterTarget(const FString& Name, UObject* Target, bool bIsSingleton = true); + + /** + * Unregister a target + * @param Name - The target name to unregister + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Router") + void UnregisterTarget(const FString& Name); + + /** + * Check if a target is registered + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Router") + bool IsTargetRegistered(const FString& Name) const; + + /** + * Get all registered targets + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Router") + TArray GetRegisteredTargets() const; + + // ============================================================ + // MARK: - Method Registration + // ============================================================ + + /** + * Register a method handler for a target + * @param TargetName - The target that receives the method call + * @param MethodName - The method name to handle + * @param Delegate - The delegate to call when the method is received + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Router") + void RegisterMethod(const FString& TargetName, const FString& MethodName, FFlutterMethodDelegate Delegate); + + /** + * Register a binary method handler for a target + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Router") + void RegisterBinaryMethod(const FString& TargetName, const FString& MethodName, FFlutterBinaryMethodDelegate Delegate); + + /** + * Unregister a method handler + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Router") + void UnregisterMethod(const FString& TargetName, const FString& MethodName); + + // ============================================================ + // MARK: - Message Routing + // ============================================================ + + /** + * Route a message to the appropriate target + * @param Target - The target name + * @param Method - The method name + * @param Data - The message data (JSON string) + * @return True if the message was routed successfully + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Router") + bool RouteMessage(const FString& Target, const FString& Method, const FString& Data); + + /** + * Route a binary message to the appropriate target + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Router") + bool RouteBinaryMessage(const FString& Target, const FString& Method, const TArray& Data); + + // ============================================================ + // MARK: - Message Queuing + // ============================================================ + + /** + * Queue a message for later delivery (e.g., before targets are registered) + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Router") + void QueueMessage(const FString& Target, const FString& Method, const FString& Data); + + /** + * Flush all queued messages to their targets + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Router") + void FlushQueue(); + + /** + * Clear all queued messages without delivering them + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Router") + void ClearQueue(); + + // ============================================================ + // MARK: - Statistics + // ============================================================ + + /** + * Get routing statistics + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Router") + FFlutterRouterStatistics GetStatistics() const; + + /** + * Reset statistics + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Router") + void ResetStatistics(); + + // ============================================================ + // MARK: - Configuration + // ============================================================ + + /** + * Enable or disable message queuing for unknown targets + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Router") + void SetQueueUnknownTargets(bool bEnable); + + /** + * Set maximum queue size + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Router") + void SetMaxQueueSize(int32 Size); + +private: + // Singleton instance + static UFlutterMessageRouter* Instance; + + // Registered targets + TMap Targets; + TMap SingletonFlags; + + // Cached delegates for fast lookup + TMap CachedDelegates; + TMap CachedBinaryDelegates; + + // Message queue for pre-ready messages + TArray MessageQueue; + + // Configuration + bool bQueueUnknownTargets; + int32 MaxQueueSize; + + // Statistics + mutable FFlutterRouterStatistics Statistics; + + // Helper to generate cache key + FString GetCacheKey(const FString& Target, const FString& Method) const; + + // Try to route via cached delegate + bool TryRouteCached(const FString& CacheKey, const FString& Method, const FString& Data); + bool TryRouteBinaryCached(const FString& CacheKey, const FString& Method, const TArray& Data); +}; + +/** + * Macro for easy method registration in constructors + */ +#define FLUTTER_REGISTER_METHOD(Router, Target, MethodName, Function) \ + { \ + FFlutterMethodDelegate Delegate; \ + Delegate.BindDynamic(this, &Function); \ + Router->RegisterMethod(Target, MethodName, Delegate); \ + } diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/FlutterPlugin.h b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/FlutterPlugin.h new file mode 100644 index 0000000..c19749b --- /dev/null +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/FlutterPlugin.h @@ -0,0 +1,14 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "Modules/ModuleManager.h" + +class FFlutterPluginModule : public IModuleInterface +{ +public: + /** IModuleInterface implementation */ + virtual void StartupModule() override; + virtual void ShutdownModule() override; +}; diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/UnrealBridge.h b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/UnrealBridge.h new file mode 100644 index 0000000..7e41beb --- /dev/null +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/UnrealBridge.h @@ -0,0 +1,120 @@ +// +// UnrealBridge.h +// FlutterPlugin +// +// Flat C ABI across the UnrealFramework boundary. +// +// This header is deliberately free of Unreal types. The Flutter side compiles +// it inside the host app, which must not need CoreMinimal.h, UBT include paths +// or any engine symbols. Everything crossing the boundary is a C primitive. +// +// Direction of travel: +// Flutter -> Unreal UnrealBridge_SendToUnreal and friends +// Unreal -> Flutter callbacks registered with UnrealBridge_Set*Callback +// +// Threading: every UnrealBridge_* entry point is safe to call from any thread +// and hops to the game thread internally. Callbacks fire on the GAME thread, +// so the Flutter side must marshal to the main thread before touching UIKit. +// + +#ifndef UnrealBridge_h +#define UnrealBridge_h + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/// Exported from UnrealFramework so the host app can link against it. +#define UNREALBRIDGE_API __attribute__((visibility("default"))) + +// ============================================================ +// MARK: - Unreal to Flutter +// ============================================================ + +/// A string message from Unreal. Pointers are valid only for the duration of +/// the call; copy anything you need to keep. +typedef void (*UnrealMessageCallback)(const char* target, + const char* method, + const char* data); + +/// Binary payload from Unreal. `data` is valid only for the duration of the +/// call. `checksum` is the CRC32 Unreal computed over the payload. +typedef void (*UnrealBinaryCallback)(const char* target, + const char* method, + const void* data, + int32_t length, + int32_t checksum); + +/// Register callbacks. Pass NULL to unregister. Registering replaces any +/// previous callback rather than chaining. +/// +/// There is no separate level-loaded callback: Unreal reports level loads +/// through the message callback with target "FlutterBridge" and method +/// "onLevelLoaded", carrying the level name as data. +UNREALBRIDGE_API void UnrealBridge_SetMessageCallback(UnrealMessageCallback callback); +UNREALBRIDGE_API void UnrealBridge_SetBinaryCallback(UnrealBinaryCallback callback); + +// ============================================================ +// MARK: - Flutter to Unreal +// ============================================================ + +UNREALBRIDGE_API void UnrealBridge_SendToUnreal(const char* target, + const char* method, + const char* data); + +UNREALBRIDGE_API void UnrealBridge_SendBinaryToUnreal(const char* target, + const char* method, + const void* data, + int32_t length, + int32_t checksum); + +UNREALBRIDGE_API void UnrealBridge_ExecuteConsoleCommand(const char* command); + +UNREALBRIDGE_API void UnrealBridge_LoadLevel(const char* levelName); + +/// Quality levels are 0 to 4 (Low, Medium, High, Epic, Cinematic). Pass -1 for +/// any value that should be left alone. +UNREALBRIDGE_API void UnrealBridge_ApplyQualitySettings(int32_t qualityLevel, + int32_t antiAliasing, + int32_t shadow, + int32_t postProcess, + int32_t texture, + int32_t effects, + int32_t foliage, + int32_t viewDistance); + +/// Read the current quality settings into a caller-owned array, in this order: +/// +/// 0 antiAliasing, 1 shadow, 2 postProcess, 3 texture, +/// 4 effects, 5 foliage, 6 viewDistance +/// +/// Note this is the Apply order minus the leading overall quality level, which +/// Unreal exposes no getter for. Returns the number of values written, or 0 if +/// the bridge is not ready or `capacity` is too small. +UNREALBRIDGE_API int32_t UnrealBridge_GetQualitySettings(int32_t* outValues, + int32_t capacity); + +/// Number of values UnrealBridge_GetQualitySettings writes when it succeeds. +#define UNREALBRIDGE_QUALITY_VALUE_COUNT 7 + +// ============================================================ +// MARK: - Lifecycle +// ============================================================ + +UNREALBRIDGE_API void UnrealBridge_Pause(int32_t paused); + +/// Tear the bridge down. Clears the registered callbacks and tells Unreal the +/// host is going away. +UNREALBRIDGE_API void UnrealBridge_Stop(void); + +/// Whether an AFlutterBridge actor has registered itself. Everything above is +/// safe to call when this returns 0, it just does nothing. +UNREALBRIDGE_API int32_t UnrealBridge_IsReady(void); + +#ifdef __cplusplus +} +#endif + +#endif /* UnrealBridge_h */ diff --git a/example/unreal/demo/Source/GameFrameworkProject.Target.cs b/example/unreal/demo/Source/GameFrameworkProject.Target.cs new file mode 100644 index 0000000..57caaca --- /dev/null +++ b/example/unreal/demo/Source/GameFrameworkProject.Target.cs @@ -0,0 +1,30 @@ +using UnrealBuildTool; +using System.Collections.Generic; + +public class GameFrameworkProjectTarget : TargetRules +{ + public GameFrameworkProjectTarget(TargetInfo Target) : base(Target) + { + Type = TargetType.Game; + DefaultBuildSettings = BuildSettingsVersion.V6; + IncludeOrderVersion = EngineIncludeOrderVersion.Latest; + ExtraModuleNames.Add("GameFrameworkProject"); + + // Build as a dynamic library so a Flutter host can link the result. + // + // iOS drives this from Config/DefaultEngine.ini instead, where + // bBuildAsFramework under IOSRuntimeSettings makes UEBuildIOS set + // bShouldCompileAsDLL for us. Mac has no equivalent switch, so the + // target asks directly. + // + // The unique build environment is required: without it UBT refuses, + // because the project would be changing a setting it shares with the + // installed engine's own build products. + if (Target.Platform == UnrealTargetPlatform.Mac) + { + LinkType = TargetLinkType.Monolithic; + bOverrideBuildEnvironment = true; + bShouldCompileAsDLL = true; + } + } +} diff --git a/example/unreal/demo/Source/GameFrameworkProject/FlutterActor.cpp b/example/unreal/demo/Source/GameFrameworkProject/FlutterActor.cpp new file mode 100644 index 0000000..6b1a2af --- /dev/null +++ b/example/unreal/demo/Source/GameFrameworkProject/FlutterActor.cpp @@ -0,0 +1,181 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include "FlutterActor.h" +#include "FlutterBridge.h" +#include "FlutterMessageRouter.h" +#include "FlutterBlueprintLibrary.h" + +AFlutterActor::AFlutterActor() +{ + PrimaryActorTick.bCanEverTick = false; + bAutoRegister = true; + bIsSingleton = true; + bIsRegistered = false; + CachedBridge = nullptr; + CachedRouter = nullptr; +} + +void AFlutterActor::BeginPlay() +{ + Super::BeginPlay(); + + if (bAutoRegister) + { + RegisterWithFlutter(); + } +} + +void AFlutterActor::EndPlay(const EEndPlayReason::Type EndPlayReason) +{ + if (bIsRegistered) + { + UnregisterFromFlutter(); + } + + Super::EndPlay(EndPlayReason); +} + +// ============================================================ +// MARK: - Flutter Configuration +// ============================================================ + +FString AFlutterActor::GetFlutterTargetName_Implementation() const +{ + // Default to class name + return GetClass()->GetName(); +} + +// ============================================================ +// MARK: - Message Handling +// ============================================================ + +void AFlutterActor::HandleFlutterMessage_Implementation(const FString& Method, const FString& Data) +{ + // Default implementation - log message + UE_LOG(LogTemp, Log, TEXT("[FlutterActor] %s received: Method=%s"), *GetFlutterTargetName(), *Method); +} + +void AFlutterActor::HandleFlutterBinaryMessage_Implementation(const FString& Method, const TArray& Data) +{ + // Default implementation - log message + UE_LOG(LogTemp, Log, TEXT("[FlutterActor] %s received binary: Method=%s, Size=%d"), *GetFlutterTargetName(), *Method, Data.Num()); +} + +// ============================================================ +// MARK: - Sending Messages +// ============================================================ + +void AFlutterActor::SendToFlutter(const FString& Method, const FString& Data) +{ + AFlutterBridge* Bridge = GetFlutterBridge(); + if (Bridge) + { + Bridge->SendToFlutter(GetFlutterTargetName(), Method, Data); + } + else + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterActor] Cannot send message - Flutter bridge not available")); + } +} + +void AFlutterActor::SendJsonToFlutter(const FString& Method, const TMap& JsonData) +{ + FString JsonString = UFlutterBlueprintLibrary::MapToJsonString(JsonData); + SendToFlutter(Method, JsonString); +} + +void AFlutterActor::SendBinaryToFlutter(const FString& Method, const TArray& Data) +{ + AFlutterBridge* Bridge = GetFlutterBridge(); + if (Bridge) + { + Bridge->SendBinaryToFlutter(GetFlutterTargetName(), Method, Data); + } + else + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterActor] Cannot send binary - Flutter bridge not available")); + } +} + +// ============================================================ +// MARK: - Utilities +// ============================================================ + +bool AFlutterActor::IsFlutterAvailable() const +{ + return GetFlutterBridge() != nullptr; +} + +AFlutterBridge* AFlutterActor::GetFlutterBridge() const +{ + if (CachedBridge) + { + return CachedBridge; + } + + // Cast away const for caching + AFlutterActor* MutableThis = const_cast(this); + MutableThis->CachedBridge = AFlutterBridge::GetInstance(this); + return CachedBridge; +} + +UFlutterMessageRouter* AFlutterActor::GetFlutterRouter() const +{ + if (CachedRouter) + { + return CachedRouter; + } + + // Cast away const for caching + AFlutterActor* MutableThis = const_cast(this); + MutableThis->CachedRouter = UFlutterMessageRouter::Get(this); + return CachedRouter; +} + +// ============================================================ +// MARK: - Registration +// ============================================================ + +void AFlutterActor::RegisterWithFlutter() +{ + UFlutterMessageRouter* Router = GetFlutterRouter(); + if (Router) + { + FString TargetName = GetFlutterTargetName(); + + // Register target + Router->RegisterTarget(TargetName, this, bIsSingleton); + + // Register message handler + FFlutterMethodDelegate MessageDelegate; + MessageDelegate.BindDynamic(this, &AFlutterActor::OnFlutterMessageInternal); + Router->RegisterMethod(TargetName, TEXT("*"), MessageDelegate); // Wildcard registration + + bIsRegistered = true; + UE_LOG(LogTemp, Log, TEXT("[FlutterActor] Registered: %s"), *TargetName); + } +} + +void AFlutterActor::UnregisterFromFlutter() +{ + UFlutterMessageRouter* Router = GetFlutterRouter(); + if (Router && bIsRegistered) + { + FString TargetName = GetFlutterTargetName(); + Router->UnregisterTarget(TargetName); + bIsRegistered = false; + UE_LOG(LogTemp, Log, TEXT("[FlutterActor] Unregistered: %s"), *TargetName); + } +} + +void AFlutterActor::OnFlutterMessageInternal(const FString& Method, const FString& Data) +{ + // Call the overridable handler + HandleFlutterMessage(Method, Data); +} + +void AFlutterActor::OnFlutterBinaryMessageInternal(const FString& Method, const TArray& Data) +{ + // Call the overridable handler + HandleFlutterBinaryMessage(Method, Data); +} diff --git a/example/unreal/demo/Source/GameFrameworkProject/FlutterActor.h b/example/unreal/demo/Source/GameFrameworkProject/FlutterActor.h new file mode 100644 index 0000000..b1aaed3 --- /dev/null +++ b/example/unreal/demo/Source/GameFrameworkProject/FlutterActor.h @@ -0,0 +1,183 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "GameFramework/Actor.h" +#include "FlutterActor.generated.h" + +// Forward declarations +class AFlutterBridge; +class UFlutterMessageRouter; + +/** + * Flutter Actor - Base class for Actors that integrate with Flutter + * + * Provides automatic registration with the Flutter message router + * and convenient methods for Flutter communication. + * + * Usage: + * 1. Create a subclass of AFlutterActor + * 2. Override GetFlutterTargetName() to set your target name + * 3. Implement HandleFlutterMessage() for custom message handling + * 4. Use SendToFlutter() to send messages back + * + * Example: + * ```cpp + * UCLASS() + * class AMyGameActor : public AFlutterActor + * { + * GENERATED_BODY() + * + * protected: + * virtual FString GetFlutterTargetName() const override { return TEXT("MyGameActor"); } + * + * virtual void HandleFlutterMessage_Implementation(const FString& Method, const FString& Data) override + * { + * if (Method == TEXT("doAction")) + * { + * // Handle action + * SendToFlutter(TEXT("actionComplete"), TEXT("{}")); + * } + * } + * }; + * ``` + */ +UCLASS(Abstract, Blueprintable, BlueprintType) +class AFlutterActor : public AActor +{ + GENERATED_BODY() + +public: + AFlutterActor(); + +protected: + virtual void BeginPlay() override; + virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override; + +public: + // ============================================================ + // MARK: - Flutter Configuration + // ============================================================ + + /** + * Get the target name this actor registers as + * Override in subclasses to set custom target name + */ + UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "Flutter") + FString GetFlutterTargetName() const; + virtual FString GetFlutterTargetName_Implementation() const; + + /** + * Whether this actor should auto-register with the Flutter router + */ + UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Flutter") + bool bAutoRegister; + + /** + * Whether this is a singleton (only one instance can be registered) + */ + UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Flutter") + bool bIsSingleton; + + // ============================================================ + // MARK: - Message Handling + // ============================================================ + + /** + * Called when a message is received from Flutter + * Override in subclasses or Blueprint to handle messages + */ + UFUNCTION(BlueprintNativeEvent, Category = "Flutter") + void HandleFlutterMessage(const FString& Method, const FString& Data); + virtual void HandleFlutterMessage_Implementation(const FString& Method, const FString& Data); + + /** + * Called when binary data is received from Flutter + */ + UFUNCTION(BlueprintNativeEvent, Category = "Flutter") + void HandleFlutterBinaryMessage(const FString& Method, const TArray& Data); + virtual void HandleFlutterBinaryMessage_Implementation(const FString& Method, const TArray& Data); + + // ============================================================ + // MARK: - Sending Messages + // ============================================================ + + /** + * Send a message to Flutter + * @param Method - The method name (e.g., "onStateChanged") + * @param Data - The data to send (JSON string) + */ + UFUNCTION(BlueprintCallable, Category = "Flutter") + void SendToFlutter(const FString& Method, const FString& Data); + + /** + * Send JSON data to Flutter + * @param Method - The method name + * @param JsonData - Key-value pairs to send as JSON + */ + UFUNCTION(BlueprintCallable, Category = "Flutter") + void SendJsonToFlutter(const FString& Method, const TMap& JsonData); + + /** + * Send binary data to Flutter + */ + UFUNCTION(BlueprintCallable, Category = "Flutter") + void SendBinaryToFlutter(const FString& Method, const TArray& Data); + + // ============================================================ + // MARK: - Utilities + // ============================================================ + + /** + * Check if Flutter bridge is available + */ + UFUNCTION(BlueprintCallable, Category = "Flutter") + bool IsFlutterAvailable() const; + + /** + * Get the Flutter bridge instance + */ + UFUNCTION(BlueprintCallable, Category = "Flutter") + AFlutterBridge* GetFlutterBridge() const; + + /** + * Get the Flutter message router + */ + UFUNCTION(BlueprintCallable, Category = "Flutter") + UFlutterMessageRouter* GetFlutterRouter() const; + +protected: + /** + * Register this actor with the Flutter router + */ + virtual void RegisterWithFlutter(); + + /** + * Unregister this actor from the Flutter router + */ + virtual void UnregisterFromFlutter(); + + /** + * Internal callback for message routing + */ + UFUNCTION() + void OnFlutterMessageInternal(const FString& Method, const FString& Data); + + /** + * Internal callback for binary message routing + */ + UFUNCTION() + void OnFlutterBinaryMessageInternal(const FString& Method, const TArray& Data); + +private: + // Cached references + UPROPERTY() + AFlutterBridge* CachedBridge; + + UPROPERTY() + UFlutterMessageRouter* CachedRouter; + + // Registration state + bool bIsRegistered; +}; diff --git a/example/unreal/demo/Source/GameFrameworkProject/FlutterGameMode.cpp b/example/unreal/demo/Source/GameFrameworkProject/FlutterGameMode.cpp new file mode 100644 index 0000000..f80f533 --- /dev/null +++ b/example/unreal/demo/Source/GameFrameworkProject/FlutterGameMode.cpp @@ -0,0 +1,299 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include "FlutterGameMode.h" +#include "FlutterBridge.h" +#include "FlutterMessageRouter.h" +#include "Engine/World.h" +#include "TimerManager.h" +#include "Dom/JsonObject.h" +#include "Serialization/JsonWriter.h" +#include "Serialization/JsonSerializer.h" + +AFlutterGameMode::AFlutterGameMode() +{ + bIsGameRunning = false; + bIsGamePaused = false; + CurrentScore = 0; + CurrentLevel = 1; + FlutterTargetName = TEXT("GameMode"); + bAutoSyncState = true; + StateSyncInterval = 1.0f; + FlutterBridge = nullptr; + MessageRouter = nullptr; +} + +void AFlutterGameMode::BeginPlay() +{ + Super::BeginPlay(); + InitializeFlutter(); +} + +void AFlutterGameMode::EndPlay(const EEndPlayReason::Type EndPlayReason) +{ + // Clear timer + if (GetWorld()) + { + GetWorld()->GetTimerManager().ClearTimer(StateSyncTimerHandle); + } + + // Unregister from router + if (MessageRouter) + { + MessageRouter->UnregisterTarget(FlutterTargetName); + } + + Super::EndPlay(EndPlayReason); +} + +void AFlutterGameMode::InitializeFlutter() +{ + // Get Flutter bridge + FlutterBridge = AFlutterBridge::GetInstance(this); + MessageRouter = UFlutterMessageRouter::Get(this); + + if (MessageRouter) + { + // Register as target + MessageRouter->RegisterTarget(FlutterTargetName, this, true); + + // Register message handlers + FFlutterMethodDelegate Delegate; + Delegate.BindDynamic(this, &AFlutterGameMode::HandleFlutterMessage); + MessageRouter->RegisterMethod(FlutterTargetName, TEXT("playerAction"), Delegate); + MessageRouter->RegisterMethod(FlutterTargetName, TEXT("requestState"), Delegate); + MessageRouter->RegisterMethod(FlutterTargetName, TEXT("setLevel"), Delegate); + + UE_LOG(LogTemp, Log, TEXT("[FlutterGameMode] Registered with Flutter router")); + } + + // Start state sync timer if enabled + if (bAutoSyncState && StateSyncInterval > 0.0f && GetWorld()) + { + GetWorld()->GetTimerManager().SetTimer( + StateSyncTimerHandle, + this, + &AFlutterGameMode::SyncGameState, + StateSyncInterval, + true + ); + } +} + +// ============================================================ +// MARK: - Game State +// ============================================================ + +void AFlutterGameMode::StartGame() +{ + if (!bIsGameRunning) + { + bIsGameRunning = true; + bIsGamePaused = false; + + NotifyFlutter(TEXT("gameStarted"), TEXT("{}")); + OnGameStarted(); + + UE_LOG(LogTemp, Log, TEXT("[FlutterGameMode] Game started")); + } +} + +void AFlutterGameMode::PauseGame() +{ + if (bIsGameRunning && !bIsGamePaused) + { + bIsGamePaused = true; + + NotifyFlutter(TEXT("gamePaused"), TEXT("{}")); + OnGamePaused(); + + UE_LOG(LogTemp, Log, TEXT("[FlutterGameMode] Game paused")); + } +} + +void AFlutterGameMode::ResumeGame() +{ + if (bIsGameRunning && bIsGamePaused) + { + bIsGamePaused = false; + + NotifyFlutter(TEXT("gameResumed"), TEXT("{}")); + OnGameResumed(); + + UE_LOG(LogTemp, Log, TEXT("[FlutterGameMode] Game resumed")); + } +} + +void AFlutterGameMode::StopGame() +{ + if (bIsGameRunning) + { + bIsGameRunning = false; + bIsGamePaused = false; + + NotifyFlutter(TEXT("gameStopped"), TEXT("{}")); + OnGameStopped(); + + UE_LOG(LogTemp, Log, TEXT("[FlutterGameMode] Game stopped")); + } +} + +void AFlutterGameMode::GameOver(const FString& Reason) +{ + bIsGameRunning = false; + bIsGamePaused = false; + + // Build JSON + TSharedPtr JsonObject = MakeShareable(new FJsonObject); + JsonObject->SetStringField(TEXT("reason"), Reason); + JsonObject->SetNumberField(TEXT("finalScore"), CurrentScore); + JsonObject->SetNumberField(TEXT("finalLevel"), CurrentLevel); + + FString JsonString; + TSharedRef> Writer = TJsonWriterFactory<>::Create(&JsonString); + FJsonSerializer::Serialize(JsonObject.ToSharedRef(), Writer); + + NotifyFlutter(TEXT("gameOver"), JsonString); + OnGameOver(Reason); + + UE_LOG(LogTemp, Log, TEXT("[FlutterGameMode] Game over: %s"), *Reason); +} + +void AFlutterGameMode::RestartGame() +{ + StopGame(); + ResetScore(); + CurrentLevel = 1; + StartGame(); +} + +// ============================================================ +// MARK: - Score Management +// ============================================================ + +void AFlutterGameMode::SetScore(int32 NewScore) +{ + int32 Delta = NewScore - CurrentScore; + CurrentScore = NewScore; + + // Build JSON + TSharedPtr JsonObject = MakeShareable(new FJsonObject); + JsonObject->SetNumberField(TEXT("score"), CurrentScore); + JsonObject->SetNumberField(TEXT("delta"), Delta); + + FString JsonString; + TSharedRef> Writer = TJsonWriterFactory<>::Create(&JsonString); + FJsonSerializer::Serialize(JsonObject.ToSharedRef(), Writer); + + NotifyFlutter(TEXT("scoreChanged"), JsonString); + OnScoreChanged(CurrentScore, Delta); +} + +void AFlutterGameMode::AddScore(int32 Points) +{ + SetScore(CurrentScore + Points); +} + +void AFlutterGameMode::ResetScore() +{ + SetScore(0); +} + +// ============================================================ +// MARK: - Level Management +// ============================================================ + +void AFlutterGameMode::SetLevel(int32 NewLevel) +{ + CurrentLevel = FMath::Max(1, NewLevel); + + TSharedPtr JsonObject = MakeShareable(new FJsonObject); + JsonObject->SetNumberField(TEXT("level"), CurrentLevel); + + FString JsonString; + TSharedRef> Writer = TJsonWriterFactory<>::Create(&JsonString); + FJsonSerializer::Serialize(JsonObject.ToSharedRef(), Writer); + + NotifyFlutter(TEXT("levelChanged"), JsonString); + OnLevelChanged(CurrentLevel); +} + +void AFlutterGameMode::NextLevel() +{ + SetLevel(CurrentLevel + 1); +} + +void AFlutterGameMode::LoadGameLevel(const FString& LevelName) +{ + if (FlutterBridge) + { + FlutterBridge->LoadLevel(LevelName); + } +} + +// ============================================================ +// MARK: - Flutter Communication +// ============================================================ + +void AFlutterGameMode::SendGameEvent(const FString& EventName, const FString& EventData) +{ + NotifyFlutter(EventName, EventData); +} + +void AFlutterGameMode::SyncGameState() +{ + TSharedPtr JsonObject = MakeShareable(new FJsonObject); + JsonObject->SetBoolField(TEXT("isRunning"), bIsGameRunning); + JsonObject->SetBoolField(TEXT("isPaused"), bIsGamePaused); + JsonObject->SetNumberField(TEXT("score"), CurrentScore); + JsonObject->SetNumberField(TEXT("level"), CurrentLevel); + + FString JsonString; + TSharedRef> Writer = TJsonWriterFactory<>::Create(&JsonString); + FJsonSerializer::Serialize(JsonObject.ToSharedRef(), Writer); + + NotifyFlutter(TEXT("stateSync"), JsonString); +} + +void AFlutterGameMode::NotifyFlutter(const FString& Event, const FString& Data) +{ + if (FlutterBridge) + { + FlutterBridge->SendToFlutter(FlutterTargetName, Event, Data); + } +} + +// ============================================================ +// MARK: - Flutter Message Handlers +// ============================================================ + +void AFlutterGameMode::HandleFlutterMessage(const FString& Method, const FString& Data) +{ + if (Method == TEXT("playerAction")) + { + // Parse action from Data + TSharedPtr JsonObject; + TSharedRef> Reader = TJsonReaderFactory<>::Create(Data); + + if (FJsonSerializer::Deserialize(Reader, JsonObject) && JsonObject.IsValid()) + { + FString Action = JsonObject->GetStringField(TEXT("action")); + FString ActionData = JsonObject->GetStringField(TEXT("data")); + OnPlayerAction(Action, ActionData); + } + } + else if (Method == TEXT("requestState")) + { + SyncGameState(); + } + else if (Method == TEXT("setLevel")) + { + TSharedPtr JsonObject; + TSharedRef> Reader = TJsonReaderFactory<>::Create(Data); + + if (FJsonSerializer::Deserialize(Reader, JsonObject) && JsonObject.IsValid()) + { + int32 Level = JsonObject->GetIntegerField(TEXT("level")); + SetLevel(Level); + } + } +} diff --git a/example/unreal/demo/Source/GameFrameworkProject/FlutterGameMode.h b/example/unreal/demo/Source/GameFrameworkProject/FlutterGameMode.h new file mode 100644 index 0000000..de064ff --- /dev/null +++ b/example/unreal/demo/Source/GameFrameworkProject/FlutterGameMode.h @@ -0,0 +1,264 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "GameFramework/GameModeBase.h" +#include "FlutterGameMode.generated.h" + +// Forward declarations +class AFlutterBridge; +class UFlutterMessageRouter; + +/** + * Flutter Game Mode - Base GameMode with Flutter integration + * + * Provides a ready-to-use GameMode that integrates with Flutter + * for game state management, scoring, and level control. + * + * Features: + * - Automatic Flutter bridge setup + * - Game state synchronization (start, pause, resume, stop) + * - Score tracking and updates + * - Level management + * - Player action handling + * + * Usage: + * 1. Create a subclass or use directly as your GameMode + * 2. Call game state methods from Blueprint or C++ + * 3. Flutter receives state updates automatically + * + * Example Blueprint: + * - On game start: Call StartGame() + * - On player death: Call GameOver("Player died") + * - On score: Call AddScore(100) + */ +UCLASS(Blueprintable, BlueprintType) +class AFlutterGameMode : public AGameModeBase +{ + GENERATED_BODY() + +public: + AFlutterGameMode(); + +protected: + virtual void BeginPlay() override; + virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override; + +public: + // ============================================================ + // MARK: - Game State + // ============================================================ + + /** + * Start the game + * Notifies Flutter and fires OnGameStarted + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Game") + void StartGame(); + + /** + * Pause the game + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Game") + void PauseGame(); + + /** + * Resume the game + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Game") + void ResumeGame(); + + /** + * Stop/end the game + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Game") + void StopGame(); + + /** + * Game over with reason + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Game") + void GameOver(const FString& Reason); + + /** + * Restart the game + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Game") + void RestartGame(); + + // ============================================================ + // MARK: - Score Management + // ============================================================ + + /** + * Set the current score + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Score") + void SetScore(int32 NewScore); + + /** + * Add to the current score + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Score") + void AddScore(int32 Points); + + /** + * Get the current score + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Score") + int32 GetScore() const { return CurrentScore; } + + /** + * Reset the score to zero + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Score") + void ResetScore(); + + // ============================================================ + // MARK: - Level Management + // ============================================================ + + /** + * Set the current level + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Level") + void SetLevel(int32 NewLevel); + + /** + * Advance to next level + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Level") + void NextLevel(); + + /** + * Get the current level + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Level") + int32 GetCurrentLevel() const { return CurrentLevel; } + + /** + * Load a level by name + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Level") + void LoadGameLevel(const FString& LevelName); + + // ============================================================ + // MARK: - Flutter Communication + // ============================================================ + + /** + * Send a custom event to Flutter + */ + UFUNCTION(BlueprintCallable, Category = "Flutter") + void SendGameEvent(const FString& EventName, const FString& EventData); + + /** + * Send the current game state to Flutter + */ + UFUNCTION(BlueprintCallable, Category = "Flutter") + void SyncGameState(); + + // ============================================================ + // MARK: - Blueprint Events + // ============================================================ + + /** + * Called when the game starts + */ + UFUNCTION(BlueprintImplementableEvent, Category = "Flutter|Events") + void OnGameStarted(); + + /** + * Called when the game is paused + */ + UFUNCTION(BlueprintImplementableEvent, Category = "Flutter|Events") + void OnGamePaused(); + + /** + * Called when the game is resumed + */ + UFUNCTION(BlueprintImplementableEvent, Category = "Flutter|Events") + void OnGameResumed(); + + /** + * Called when the game stops + */ + UFUNCTION(BlueprintImplementableEvent, Category = "Flutter|Events") + void OnGameStopped(); + + /** + * Called on game over + */ + UFUNCTION(BlueprintImplementableEvent, Category = "Flutter|Events") + void OnGameOver(const FString& Reason); + + /** + * Called when score changes + */ + UFUNCTION(BlueprintImplementableEvent, Category = "Flutter|Events") + void OnScoreChanged(int32 NewScore, int32 Delta); + + /** + * Called when level changes + */ + UFUNCTION(BlueprintImplementableEvent, Category = "Flutter|Events") + void OnLevelChanged(int32 NewLevel); + + /** + * Called when a player action is received from Flutter + */ + UFUNCTION(BlueprintImplementableEvent, Category = "Flutter|Events") + void OnPlayerAction(const FString& Action, const FString& Data); + + // ============================================================ + // MARK: - Flutter Message Handlers + // ============================================================ + + /** + * Handle messages from Flutter + */ + UFUNCTION() + void HandleFlutterMessage(const FString& Method, const FString& Data); + +protected: + // Game state + UPROPERTY(BlueprintReadOnly, Category = "Flutter|State") + bool bIsGameRunning; + + UPROPERTY(BlueprintReadOnly, Category = "Flutter|State") + bool bIsGamePaused; + + UPROPERTY(BlueprintReadOnly, Category = "Flutter|State") + int32 CurrentScore; + + UPROPERTY(BlueprintReadOnly, Category = "Flutter|State") + int32 CurrentLevel; + + // Configuration + UPROPERTY(EditDefaultsOnly, Category = "Flutter") + FString FlutterTargetName; + + UPROPERTY(EditDefaultsOnly, Category = "Flutter") + bool bAutoSyncState; + + UPROPERTY(EditDefaultsOnly, Category = "Flutter") + float StateSyncInterval; + +private: + // Cached references + UPROPERTY() + AFlutterBridge* FlutterBridge; + + UPROPERTY() + UFlutterMessageRouter* MessageRouter; + + // State sync timer + FTimerHandle StateSyncTimerHandle; + + // Initialization + void InitializeFlutter(); + + // Send state to Flutter + void NotifyFlutter(const FString& Event, const FString& Data); +}; diff --git a/example/unreal/demo/Source/GameFrameworkProject/GameFrameworkProject.Build.cs b/example/unreal/demo/Source/GameFrameworkProject/GameFrameworkProject.Build.cs new file mode 100644 index 0000000..833bfde --- /dev/null +++ b/example/unreal/demo/Source/GameFrameworkProject/GameFrameworkProject.Build.cs @@ -0,0 +1,19 @@ +using UnrealBuildTool; + +public class GameFrameworkProject : ModuleRules +{ + public GameFrameworkProject(ReadOnlyTargetRules Target) : base(Target) + { + PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs; + + PublicDependencyModuleNames.AddRange(new string[] { + "Core", + "CoreUObject", + "Engine", + "InputCore", + "FlutterPlugin" + }); + + PrivateDependencyModuleNames.AddRange(new string[] { }); + } +} diff --git a/example/unreal/demo/Source/GameFrameworkProject/GameFrameworkProject.cpp b/example/unreal/demo/Source/GameFrameworkProject/GameFrameworkProject.cpp new file mode 100644 index 0000000..f8d025d --- /dev/null +++ b/example/unreal/demo/Source/GameFrameworkProject/GameFrameworkProject.cpp @@ -0,0 +1,6 @@ +#include "GameFrameworkProject.h" +#include "Modules/ModuleManager.h" + +IMPLEMENT_PRIMARY_GAME_MODULE(FDefaultGameModuleImpl, GameFrameworkProject, "GameFrameworkProject"); + +DEFINE_LOG_CATEGORY(LogGameFramework); diff --git a/example/unreal/demo/Source/GameFrameworkProject/GameFrameworkProject.h b/example/unreal/demo/Source/GameFrameworkProject/GameFrameworkProject.h new file mode 100644 index 0000000..5f7f9f3 --- /dev/null +++ b/example/unreal/demo/Source/GameFrameworkProject/GameFrameworkProject.h @@ -0,0 +1,5 @@ +#pragma once + +#include "CoreMinimal.h" + +DECLARE_LOG_CATEGORY_EXTERN(LogGameFramework, Log, All); diff --git a/example/unreal/demo/Source/GameFrameworkProject/README.md b/example/unreal/demo/Source/GameFrameworkProject/README.md new file mode 100644 index 0000000..1550bcc --- /dev/null +++ b/example/unreal/demo/Source/GameFrameworkProject/README.md @@ -0,0 +1,222 @@ +# Unreal Engine Flutter Integration Templates + +This directory contains ready-to-use C++ classes and Blueprints for integrating Unreal Engine with Flutter. + +## Quick Start + +1. Copy the template files to your Unreal project's Source folder +2. Add the FlutterPlugin module to your project's Build.cs +3. Create subclasses or use directly in Blueprints + +## Template Files + +### FlutterActor.h/.cpp + +Base class for any Actor that needs to communicate with Flutter. + +**Features:** +- Auto-registration with Flutter message router +- Convenient SendToFlutter() methods +- Overridable message handlers +- Singleton support + +**Usage:** +```cpp +UCLASS() +class AMyGameActor : public AFlutterActor +{ + GENERATED_BODY() + +protected: + virtual FString GetFlutterTargetName() const override + { + return TEXT("MyGameActor"); + } + + virtual void HandleFlutterMessage_Implementation( + const FString& Method, + const FString& Data) override + { + if (Method == TEXT("doSomething")) + { + // Handle the message + DoSomething(); + + // Send response + SendToFlutter(TEXT("somethingDone"), TEXT("{\"success\": true}")); + } + } +}; +``` + +### FlutterGameMode.h/.cpp + +Full-featured GameMode with Flutter integration for game state management. + +**Features:** +- Game lifecycle (start, pause, resume, stop, game over) +- Score tracking and updates +- Level management +- Automatic state synchronization +- Blueprint events for all state changes + +**Usage (Blueprint):** +1. Create a Blueprint subclass of FlutterGameMode +2. Set it as your project's default GameMode +3. Use the provided functions: + - `StartGame()` - Begin the game + - `PauseGame()` / `ResumeGame()` - Control game state + - `AddScore(Points)` - Update score + - `NextLevel()` - Advance level + - `GameOver(Reason)` - End the game + +**Usage (C++):** +```cpp +// Get the GameMode +AFlutterGameMode* GameMode = Cast(GetWorld()->GetAuthGameMode()); +if (GameMode) +{ + GameMode->AddScore(100); + GameMode->SendGameEvent(TEXT("powerUp"), TEXT("{\"type\": \"speed\"}")); +} +``` + +## Flutter Side Integration + +### Receiving Messages from Unreal + +```dart +// Listen for messages from Unreal +controller.messageStream.listen((message) { + final metadata = message.metadata; + final target = metadata['target'] as String?; + final method = metadata['method'] as String?; + + if (target == 'GameMode') { + switch (method) { + case 'scoreChanged': + final data = jsonDecode(message.data); + updateScore(data['score']); + break; + case 'gameOver': + final data = jsonDecode(message.data); + showGameOverScreen(data['reason']); + break; + } + } +}); +``` + +### Sending Messages to Unreal + +```dart +// Send player action +await controller.sendJsonMessage('GameMode', 'playerAction', { + 'action': 'jump', + 'data': '{}', +}); + +// Request current game state +await controller.sendMessage('GameMode', 'requestState', '{}'); +``` + +## Message Protocol + +### Standard Messages + +All messages follow this format: +- **Target**: The registered target name (e.g., "GameMode", "Player") +- **Method**: The action to perform (e.g., "playerAction", "scoreChanged") +- **Data**: JSON string with parameters + +### Game State Messages (from Unreal) + +| Method | Data | Description | +|--------|------|-------------| +| gameStarted | {} | Game has started | +| gamePaused | {} | Game is paused | +| gameResumed | {} | Game resumed | +| gameStopped | {} | Game stopped | +| gameOver | {reason, finalScore, finalLevel} | Game over | +| scoreChanged | {score, delta} | Score updated | +| levelChanged | {level} | Level changed | +| stateSync | {isRunning, isPaused, score, level} | Full state sync | + +### Player Actions (to Unreal) + +| Method | Data | Description | +|--------|------|-------------| +| playerAction | {action, data} | Player performed action | +| requestState | {} | Request state sync | +| setLevel | {level} | Set current level | + +## Blueprint Setup + +### Creating a Flutter-Enabled GameMode + +1. Create new Blueprint Class based on FlutterGameMode +2. Configure settings: + - **Flutter Target Name**: Name to register with (default: "GameMode") + - **Auto Sync State**: Enable periodic state sync + - **State Sync Interval**: Seconds between syncs +3. Override events as needed: + - **OnGameStarted** - Called when game starts + - **OnScoreChanged** - Called when score updates + - **OnPlayerAction** - Handle player input from Flutter + +### Creating a Flutter-Enabled Actor + +1. Create new Blueprint Class based on FlutterActor +2. Override `Get Flutter Target Name` to return your target name +3. Override `Handle Flutter Message` to process incoming messages +4. Use `Send To Flutter` to send responses + +## Best Practices + +1. **Use JSON for complex data**: Always send structured data as JSON strings +2. **Keep messages small**: Send only changed data, use delta compression for large states +3. **Handle missing data gracefully**: Check for null/missing fields in JSON +4. **Use meaningful target names**: Make them descriptive and unique +5. **Register early**: Targets should register in BeginPlay +6. **Unregister on destroy**: Clean up in EndPlay to avoid memory leaks + +## Troubleshooting + +### Messages not received + +1. Check that FlutterBridge is in the level +2. Verify target name matches between Unreal and Flutter +3. Check the message router is initialized +4. Look for registration errors in the output log + +### Score not updating + +1. Verify the GameMode is correctly set +2. Check that the Flutter listener is subscribed to messageStream +3. Ensure JSON parsing handles the correct field names + +### Performance issues + +1. Reduce state sync frequency if not needed +2. Use binary messaging for large data transfers +3. Consider message batching for high-frequency updates + +## Example Project Structure + +``` +Source/ + MyGame/ + MyGame.Build.cs + MyGameMode.h # Subclass of FlutterGameMode + MyGameMode.cpp + Player/ + MyPlayerActor.h # Subclass of FlutterActor + MyPlayerActor.cpp + UI/ + ScoreActor.h + ScoreActor.cpp +``` + +## License + +These templates are part of the GameFramework and are provided under the same license. diff --git a/example/unreal/demo/Source/GameFrameworkProject/ROTATING_CUBE_DEMO.md b/example/unreal/demo/Source/GameFrameworkProject/ROTATING_CUBE_DEMO.md new file mode 100644 index 0000000..9894120 --- /dev/null +++ b/example/unreal/demo/Source/GameFrameworkProject/ROTATING_CUBE_DEMO.md @@ -0,0 +1,482 @@ +# Rotating Cube Demo - Unreal Engine + +This guide demonstrates how to create a rotating cube in Unreal Engine that communicates with Flutter, matching the Unity rotating cube demo. + +## Overview + +The demo creates a simple 3D cube that: +- Rotates continuously in Unreal Engine +- Receives rotation speed commands from Flutter +- Sends rotation state updates to Flutter +- Can be paused/resumed from Flutter + +## Quick Setup + +### 1. Create the Rotating Cube Actor + +Create a new C++ class inheriting from `AFlutterActor`: + +**RotatingCubeActor.h** +```cpp +#pragma once + +#include "CoreMinimal.h" +#include "FlutterActor.h" +#include "RotatingCubeActor.generated.h" + +UCLASS(Blueprintable) +class ARotatingCubeActor : public AFlutterActor +{ + GENERATED_BODY() + +public: + ARotatingCubeActor(); + +protected: + virtual void BeginPlay() override; + virtual void Tick(float DeltaTime) override; + + virtual FString GetFlutterTargetName() const override; + virtual void HandleFlutterMessage_Implementation(const FString& Method, const FString& Data) override; + +public: + // Configuration + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Rotation") + float RotationSpeed; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Rotation") + FVector RotationAxis; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Rotation") + bool bIsRotating; + + // Control methods + UFUNCTION(BlueprintCallable, Category = "Rotation") + void SetRotationSpeed(float Speed); + + UFUNCTION(BlueprintCallable, Category = "Rotation") + void SetRotationAxis(const FVector& Axis); + + UFUNCTION(BlueprintCallable, Category = "Rotation") + void StartRotation(); + + UFUNCTION(BlueprintCallable, Category = "Rotation") + void StopRotation(); + + UFUNCTION(BlueprintCallable, Category = "Rotation") + void ToggleRotation(); + +private: + // Visual component + UPROPERTY(VisibleAnywhere) + UStaticMeshComponent* CubeMesh; + + // State reporting + float TimeSinceLastUpdate; + float UpdateInterval; + void SendStateUpdate(); +}; +``` + +**RotatingCubeActor.cpp** +```cpp +#include "RotatingCubeActor.h" +#include "Components/StaticMeshComponent.h" +#include "Dom/JsonObject.h" +#include "Serialization/JsonReader.h" +#include "Serialization/JsonSerializer.h" +#include "Serialization/JsonWriter.h" + +ARotatingCubeActor::ARotatingCubeActor() +{ + PrimaryActorTick.bCanEverTick = true; + + RotationSpeed = 45.0f; // degrees per second + RotationAxis = FVector(0, 0, 1); // Z-axis + bIsRotating = true; + TimeSinceLastUpdate = 0.0f; + UpdateInterval = 0.1f; // 10 updates per second + + // Create cube mesh + CubeMesh = CreateDefaultSubobject(TEXT("CubeMesh")); + RootComponent = CubeMesh; + + // Load default cube mesh + static ConstructorHelpers::FObjectFinder CubeMeshAsset( + TEXT("/Engine/BasicShapes/Cube")); + if (CubeMeshAsset.Succeeded()) + { + CubeMesh->SetStaticMesh(CubeMeshAsset.Object); + } + + // Load default material + static ConstructorHelpers::FObjectFinder CubeMaterial( + TEXT("/Engine/BasicShapes/BasicShapeMaterial")); + if (CubeMaterial.Succeeded()) + { + CubeMesh->SetMaterial(0, CubeMaterial.Object); + } + + CubeMesh->SetWorldScale3D(FVector(0.5f, 0.5f, 0.5f)); +} + +void ARotatingCubeActor::BeginPlay() +{ + Super::BeginPlay(); + + // Send initial state + SendStateUpdate(); +} + +void ARotatingCubeActor::Tick(float DeltaTime) +{ + Super::Tick(DeltaTime); + + // Rotate cube + if (bIsRotating) + { + FRotator DeltaRotation = FRotator( + RotationAxis.X * RotationSpeed * DeltaTime, + RotationAxis.Y * RotationSpeed * DeltaTime, + RotationAxis.Z * RotationSpeed * DeltaTime + ); + AddActorLocalRotation(DeltaRotation); + } + + // Send periodic updates + TimeSinceLastUpdate += DeltaTime; + if (TimeSinceLastUpdate >= UpdateInterval) + { + TimeSinceLastUpdate = 0.0f; + SendStateUpdate(); + } +} + +FString ARotatingCubeActor::GetFlutterTargetName() const +{ + return TEXT("RotatingCube"); +} + +void ARotatingCubeActor::HandleFlutterMessage_Implementation( + const FString& Method, const FString& Data) +{ + // Parse JSON data + TSharedPtr JsonObject; + TSharedRef> Reader = TJsonReaderFactory<>::Create(Data); + + if (Method == TEXT("setSpeed")) + { + if (FJsonSerializer::Deserialize(Reader, JsonObject) && JsonObject.IsValid()) + { + float Speed = JsonObject->GetNumberField(TEXT("speed")); + SetRotationSpeed(Speed); + } + } + else if (Method == TEXT("setAxis")) + { + if (FJsonSerializer::Deserialize(Reader, JsonObject) && JsonObject.IsValid()) + { + float X = JsonObject->GetNumberField(TEXT("x")); + float Y = JsonObject->GetNumberField(TEXT("y")); + float Z = JsonObject->GetNumberField(TEXT("z")); + SetRotationAxis(FVector(X, Y, Z)); + } + } + else if (Method == TEXT("start")) + { + StartRotation(); + } + else if (Method == TEXT("stop")) + { + StopRotation(); + } + else if (Method == TEXT("toggle")) + { + ToggleRotation(); + } + else if (Method == TEXT("getState")) + { + SendStateUpdate(); + } +} + +void ARotatingCubeActor::SetRotationSpeed(float Speed) +{ + RotationSpeed = Speed; + SendStateUpdate(); +} + +void ARotatingCubeActor::SetRotationAxis(const FVector& Axis) +{ + RotationAxis = Axis.GetSafeNormal(); + SendStateUpdate(); +} + +void ARotatingCubeActor::StartRotation() +{ + bIsRotating = true; + SendToFlutter(TEXT("started"), TEXT("{}")); + SendStateUpdate(); +} + +void ARotatingCubeActor::StopRotation() +{ + bIsRotating = false; + SendToFlutter(TEXT("stopped"), TEXT("{}")); + SendStateUpdate(); +} + +void ARotatingCubeActor::ToggleRotation() +{ + if (bIsRotating) + StopRotation(); + else + StartRotation(); +} + +void ARotatingCubeActor::SendStateUpdate() +{ + FRotator CurrentRotation = GetActorRotation(); + + TSharedPtr JsonObject = MakeShareable(new FJsonObject); + JsonObject->SetBoolField(TEXT("isRotating"), bIsRotating); + JsonObject->SetNumberField(TEXT("speed"), RotationSpeed); + + // Rotation axis + TSharedPtr AxisObject = MakeShareable(new FJsonObject); + AxisObject->SetNumberField(TEXT("x"), RotationAxis.X); + AxisObject->SetNumberField(TEXT("y"), RotationAxis.Y); + AxisObject->SetNumberField(TEXT("z"), RotationAxis.Z); + JsonObject->SetObjectField(TEXT("axis"), AxisObject); + + // Current rotation + TSharedPtr RotationObject = MakeShareable(new FJsonObject); + RotationObject->SetNumberField(TEXT("pitch"), CurrentRotation.Pitch); + RotationObject->SetNumberField(TEXT("yaw"), CurrentRotation.Yaw); + RotationObject->SetNumberField(TEXT("roll"), CurrentRotation.Roll); + JsonObject->SetObjectField(TEXT("rotation"), RotationObject); + + FString JsonString; + TSharedRef> Writer = TJsonWriterFactory<>::Create(&JsonString); + FJsonSerializer::Serialize(JsonObject.ToSharedRef(), Writer); + + SendToFlutter(TEXT("stateUpdate"), JsonString); +} +``` + +### 2. Flutter Integration + +**Dart Code:** +```dart +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:gameframework_unreal/gameframework_unreal.dart'; + +class RotatingCubeController extends StatefulWidget { + final UnrealController controller; + + const RotatingCubeController({required this.controller}); + + @override + _RotatingCubeControllerState createState() => _RotatingCubeControllerState(); +} + +class _RotatingCubeControllerState extends State { + bool isRotating = true; + double rotationSpeed = 45.0; + Map rotationAxis = {'x': 0, 'y': 0, 'z': 1}; + Map currentRotation = {'pitch': 0, 'yaw': 0, 'roll': 0}; + + @override + void initState() { + super.initState(); + _listenToUpdates(); + } + + void _listenToUpdates() { + widget.controller.messageStream.listen((message) { + final metadata = message.metadata; + if (metadata['target'] == 'RotatingCube') { + final method = metadata['method'] as String?; + + if (method == 'stateUpdate') { + final data = jsonDecode(message.data); + setState(() { + isRotating = data['isRotating'] ?? false; + rotationSpeed = (data['speed'] ?? 45.0).toDouble(); + rotationAxis = { + 'x': (data['axis']?['x'] ?? 0).toDouble(), + 'y': (data['axis']?['y'] ?? 0).toDouble(), + 'z': (data['axis']?['z'] ?? 1).toDouble(), + }; + currentRotation = { + 'pitch': (data['rotation']?['pitch'] ?? 0).toDouble(), + 'yaw': (data['rotation']?['yaw'] ?? 0).toDouble(), + 'roll': (data['rotation']?['roll'] ?? 0).toDouble(), + }; + }); + } + } + }); + } + + Future _setSpeed(double speed) async { + await widget.controller.sendJsonMessage( + 'RotatingCube', + 'setSpeed', + {'speed': speed}, + ); + } + + Future _toggle() async { + await widget.controller.sendMessage('RotatingCube', 'toggle', '{}'); + } + + Future _setAxis(double x, double y, double z) async { + await widget.controller.sendJsonMessage( + 'RotatingCube', + 'setAxis', + {'x': x, 'y': y, 'z': z}, + ); + } + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Rotating Cube', style: Theme.of(context).textTheme.headlineSmall), + SizedBox(height: 16), + + // Status + Row( + children: [ + Icon( + isRotating ? Icons.play_arrow : Icons.pause, + color: isRotating ? Colors.green : Colors.orange, + ), + SizedBox(width: 8), + Text(isRotating ? 'Rotating' : 'Paused'), + ], + ), + SizedBox(height: 16), + + // Speed slider + Text('Speed: ${rotationSpeed.toStringAsFixed(1)}°/s'), + Slider( + value: rotationSpeed, + min: 0, + max: 180, + onChanged: (value) { + setState(() => rotationSpeed = value); + _setSpeed(value); + }, + ), + + // Toggle button + ElevatedButton.icon( + onPressed: _toggle, + icon: Icon(isRotating ? Icons.pause : Icons.play_arrow), + label: Text(isRotating ? 'Pause' : 'Start'), + ), + + SizedBox(height: 16), + + // Current rotation display + Text('Current Rotation:'), + Text(' Pitch: ${currentRotation['pitch']?.toStringAsFixed(1)}°'), + Text(' Yaw: ${currentRotation['yaw']?.toStringAsFixed(1)}°'), + Text(' Roll: ${currentRotation['roll']?.toStringAsFixed(1)}°'), + + SizedBox(height: 16), + + // Axis presets + Text('Rotation Axis:'), + Wrap( + spacing: 8, + children: [ + ElevatedButton( + onPressed: () => _setAxis(1, 0, 0), + child: Text('X'), + ), + ElevatedButton( + onPressed: () => _setAxis(0, 1, 0), + child: Text('Y'), + ), + ElevatedButton( + onPressed: () => _setAxis(0, 0, 1), + child: Text('Z'), + ), + ElevatedButton( + onPressed: () => _setAxis(1, 1, 1), + child: Text('XYZ'), + ), + ], + ), + ], + ), + ), + ); + } +} +``` + +### 3. Blueprint Version + +For a Blueprint-only implementation: + +1. Create a new Blueprint Actor +2. Add a Static Mesh Component with a cube mesh +3. Add the following variables: + - `RotationSpeed` (Float, default 45.0) + - `RotationAxis` (Vector, default 0,0,1) + - `bIsRotating` (Boolean, default true) +4. In Event Tick: + - Add Delta Rotation using speed * delta time * axis +5. Implement Flutter message handling via the message router + +## Message Protocol + +### From Flutter to Unreal + +| Method | Data | Description | +|--------|------|-------------| +| setSpeed | {speed: float} | Set rotation speed in degrees/second | +| setAxis | {x, y, z: float} | Set rotation axis | +| start | {} | Start rotation | +| stop | {} | Stop rotation | +| toggle | {} | Toggle rotation | +| getState | {} | Request current state | + +### From Unreal to Flutter + +| Method | Data | Description | +|--------|------|-------------| +| stateUpdate | {isRotating, speed, axis, rotation} | Full state update | +| started | {} | Rotation started | +| stopped | {} | Rotation stopped | + +## Testing + +1. Add the RotatingCubeActor to your level +2. Run the Flutter app with the Unreal widget +3. Use the Flutter controls to: + - Adjust rotation speed + - Change rotation axis + - Start/stop rotation +4. Observe real-time updates in the Flutter UI + +## Comparison with Unity Demo + +| Feature | Unity | Unreal | +|---------|-------|--------| +| Base Class | FlutterMonoBehaviour | FlutterActor | +| Message Attribute | [FlutterMethod] | HandleFlutterMessage override | +| Rotation | transform.Rotate() | AddActorLocalRotation() | +| JSON Parsing | JsonUtility | FJsonSerializer | +| Update Rate | Configurable | Configurable | + +The API and message protocol are identical, ensuring consistent Flutter integration across both engines. diff --git a/example/unreal/demo/Source/GameFrameworkProject/RotatingCube.cpp b/example/unreal/demo/Source/GameFrameworkProject/RotatingCube.cpp new file mode 100644 index 0000000..53bf01c --- /dev/null +++ b/example/unreal/demo/Source/GameFrameworkProject/RotatingCube.cpp @@ -0,0 +1,311 @@ +#include "RotatingCube.h" +#include "FlutterBridge.h" +#include "Components/StaticMeshComponent.h" +#include "Materials/MaterialInstanceDynamic.h" +#include "Engine/StaticMesh.h" +#include "UObject/ConstructorHelpers.h" +#include "TimerManager.h" + +ARotatingCube::ARotatingCube() +{ + PrimaryActorTick.bCanEverTick = true; + + // Create cube mesh component + CubeMesh = CreateDefaultSubobject(TEXT("CubeMesh")); + RootComponent = CubeMesh; + + // Set default cube mesh + static ConstructorHelpers::FObjectFinder CubeMeshAsset(TEXT("/Engine/BasicShapes/Cube")); + if (CubeMeshAsset.Succeeded()) + { + CubeMesh->SetStaticMesh(CubeMeshAsset.Object); + } + + // Set default values + bAutoRegister = true; +} + +void ARotatingCube::BeginPlay() +{ + Super::BeginPlay(); + + // Create dynamic material + if (CubeMesh && CubeMesh->GetMaterial(0)) + { + DynamicMaterial = CubeMesh->CreateAndSetMaterialInstanceDynamic(0); + UpdateMaterialColor(); + } + + // Notify Flutter that we're ready + SendToFlutter(TEXT("onReady"), TEXT("true")); + + // Set up auto-sync timer if enabled + if (SyncIntervalSeconds > 0.0f) + { + GetWorldTimerManager().SetTimer( + SyncTimerHandle, + this, + &ARotatingCube::SyncStateToFlutter, + SyncIntervalSeconds, + true + ); + } + + UE_LOG(LogTemp, Log, TEXT("[RotatingCube] BeginPlay - Speed: %.1f, Axis: %s"), + RotationSpeed, *RotationAxis.ToString()); +} + +void ARotatingCube::Tick(float DeltaTime) +{ + Super::Tick(DeltaTime); + + if (bIsRotating && RotationSpeed != 0.0f) + { + // Calculate rotation delta + float DeltaRotation = RotationSpeed * DeltaTime; + CurrentRotationAngle += DeltaRotation; + + // Wrap angle + if (CurrentRotationAngle > 360.0f) CurrentRotationAngle -= 360.0f; + if (CurrentRotationAngle < -360.0f) CurrentRotationAngle += 360.0f; + + // Apply rotation + FRotator DeltaRotator = FRotator::ZeroRotator; + if (RotationAxis.X != 0.0f) DeltaRotator.Roll = DeltaRotation * RotationAxis.X; + if (RotationAxis.Y != 0.0f) DeltaRotator.Pitch = DeltaRotation * RotationAxis.Y; + if (RotationAxis.Z != 0.0f) DeltaRotator.Yaw = DeltaRotation * RotationAxis.Z; + + AddActorLocalRotation(DeltaRotator); + } +} + +FString ARotatingCube::GetFlutterTargetName_Implementation() const +{ + // Use a specific name for the rotating cube demo + return TEXT("GameFrameworkDemo"); +} + +void ARotatingCube::HandleFlutterMessage_Implementation(const FString& Method, const FString& Data) +{ + UE_LOG(LogTemp, Log, TEXT("[RotatingCube] Message: %s(%s)"), *Method, *Data); + + if (Method == TEXT("setSpeed")) + { + float NewSpeed = FCString::Atof(*Data); + SetSpeed(NewSpeed); + } + else if (Method == TEXT("setAxis")) + { + FVector NewAxis = ParseAxisFromJson(Data); + SetAxis(NewAxis); + } + else if (Method == TEXT("setColor")) + { + FLinearColor NewColor = ParseColorFromJson(Data); + SetColor(NewColor); + } + else if (Method == TEXT("reset")) + { + Reset(); + } + else if (Method == TEXT("getState")) + { + SyncStateToFlutter(); + } + else if (Method == TEXT("setRotating")) + { + bool bRotate = Data.ToBool(); + SetRotating(bRotate); + } + else + { + UE_LOG(LogTemp, Warning, TEXT("[RotatingCube] Unknown method: %s"), *Method); + } +} + +void ARotatingCube::SetSpeed(float NewSpeed) +{ + RotationSpeed = FMath::Clamp(NewSpeed, -360.0f, 360.0f); + + // Notify Blueprint + OnSpeedChanged_Blueprint(RotationSpeed); + + // Notify Flutter + FString JsonData = FString::Printf(TEXT("{\"speed\":%.1f,\"rpm\":%.2f}"), RotationSpeed, GetRPM()); + SendToFlutter(TEXT("onSpeedChanged"), JsonData); + + UE_LOG(LogTemp, Log, TEXT("[RotatingCube] Speed set to: %.1f"), RotationSpeed); +} + +void ARotatingCube::SetAxis(FVector NewAxis) +{ + RotationAxis = NewAxis.GetSafeNormal(); + + // Notify Blueprint + OnAxisChanged_Blueprint(RotationAxis); + + // Notify Flutter + FString JsonData = FString::Printf(TEXT("{\"x\":%.2f,\"y\":%.2f,\"z\":%.2f}"), + RotationAxis.X, RotationAxis.Y, RotationAxis.Z); + SendToFlutter(TEXT("onAxisChanged"), JsonData); + + UE_LOG(LogTemp, Log, TEXT("[RotatingCube] Axis set to: %s"), *RotationAxis.ToString()); +} + +void ARotatingCube::SetColor(FLinearColor NewColor) +{ + CubeColor = NewColor; + UpdateMaterialColor(); + + // Notify Blueprint + OnColorChanged_Blueprint(CubeColor); + + // Notify Flutter + FString JsonData = FString::Printf(TEXT("{\"r\":%.2f,\"g\":%.2f,\"b\":%.2f,\"a\":%.2f}"), + CubeColor.R, CubeColor.G, CubeColor.B, CubeColor.A); + SendToFlutter(TEXT("onColorChanged"), JsonData); + + UE_LOG(LogTemp, Log, TEXT("[RotatingCube] Color set to: %s"), *CubeColor.ToString()); +} + +void ARotatingCube::Reset() +{ + RotationSpeed = DefaultSpeed; + RotationAxis = DefaultAxis; + CubeColor = DefaultColor; + CurrentRotationAngle = 0.0f; + bIsRotating = true; + + // Reset rotation + SetActorRotation(FRotator::ZeroRotator); + + // Update material + UpdateMaterialColor(); + + // Notify Blueprint + OnReset_Blueprint(); + + // Notify Flutter + SendToFlutter(TEXT("onReset"), GetStateJson()); + + UE_LOG(LogTemp, Log, TEXT("[RotatingCube] Reset to defaults")); +} + +FString ARotatingCube::GetStateJson() const +{ + return FString::Printf( + TEXT("{\"speed\":%.1f,\"rpm\":%.2f,\"axis\":{\"x\":%.2f,\"y\":%.2f,\"z\":%.2f},") + TEXT("\"color\":{\"r\":%.2f,\"g\":%.2f,\"b\":%.2f,\"a\":%.2f},") + TEXT("\"rotation\":%.1f,\"isRotating\":%s}"), + RotationSpeed, + GetRPM(), + RotationAxis.X, RotationAxis.Y, RotationAxis.Z, + CubeColor.R, CubeColor.G, CubeColor.B, CubeColor.A, + CurrentRotationAngle, + bIsRotating ? TEXT("true") : TEXT("false") + ); +} + +void ARotatingCube::SetRotating(bool bShouldRotate) +{ + bIsRotating = bShouldRotate; + + FString JsonData = FString::Printf(TEXT("{\"isRotating\":%s}"), bShouldRotate ? TEXT("true") : TEXT("false")); + SendToFlutter(TEXT("onRotatingChanged"), JsonData); +} + +float ARotatingCube::GetRPM() const +{ + // Degrees per second to RPM + return RotationSpeed / 6.0f; // 360 degrees = 60 seconds for 1 RPM +} + +void ARotatingCube::SyncStateToFlutter() +{ + SendToFlutter(TEXT("onState"), GetStateJson()); +} + +void ARotatingCube::UpdateMaterialColor() +{ + if (DynamicMaterial) + { + DynamicMaterial->SetVectorParameterValue(TEXT("BaseColor"), CubeColor); + } +} + +FVector ARotatingCube::ParseAxisFromJson(const FString& JsonData) +{ + FVector Result = FVector(0.0f, 1.0f, 0.0f); + + // Simple JSON parsing (for production, use FJsonSerializer) + float X = 0.0f, Y = 1.0f, Z = 0.0f; + + // Find x value + int32 XStart = JsonData.Find(TEXT("\"x\":")); + if (XStart != INDEX_NONE) + { + FString XStr = JsonData.Mid(XStart + 4, 10); + X = FCString::Atof(*XStr); + } + + // Find y value + int32 YStart = JsonData.Find(TEXT("\"y\":")); + if (YStart != INDEX_NONE) + { + FString YStr = JsonData.Mid(YStart + 4, 10); + Y = FCString::Atof(*YStr); + } + + // Find z value + int32 ZStart = JsonData.Find(TEXT("\"z\":")); + if (ZStart != INDEX_NONE) + { + FString ZStr = JsonData.Mid(ZStart + 4, 10); + Z = FCString::Atof(*ZStr); + } + + Result = FVector(X, Y, Z); + return Result; +} + +FLinearColor ARotatingCube::ParseColorFromJson(const FString& JsonData) +{ + FLinearColor Result = FLinearColor::White; + + float R = 1.0f, G = 1.0f, B = 1.0f, A = 1.0f; + + // Find r value + int32 RStart = JsonData.Find(TEXT("\"r\":")); + if (RStart != INDEX_NONE) + { + FString RStr = JsonData.Mid(RStart + 4, 10); + R = FCString::Atof(*RStr); + } + + // Find g value + int32 GStart = JsonData.Find(TEXT("\"g\":")); + if (GStart != INDEX_NONE) + { + FString GStr = JsonData.Mid(GStart + 4, 10); + G = FCString::Atof(*GStr); + } + + // Find b value + int32 BStart = JsonData.Find(TEXT("\"b\":")); + if (BStart != INDEX_NONE) + { + FString BStr = JsonData.Mid(BStart + 4, 10); + B = FCString::Atof(*BStr); + } + + // Find a value + int32 AStart = JsonData.Find(TEXT("\"a\":")); + if (AStart != INDEX_NONE) + { + FString AStr = JsonData.Mid(AStart + 4, 10); + A = FCString::Atof(*AStr); + } + + Result = FLinearColor(R, G, B, A); + return Result; +} diff --git a/example/unreal/demo/Source/GameFrameworkProject/RotatingCube.h b/example/unreal/demo/Source/GameFrameworkProject/RotatingCube.h new file mode 100644 index 0000000..0d6cc51 --- /dev/null +++ b/example/unreal/demo/Source/GameFrameworkProject/RotatingCube.h @@ -0,0 +1,139 @@ +#pragma once + +#include "CoreMinimal.h" +#include "FlutterActor.h" +#include "Components/StaticMeshComponent.h" +#include "Materials/MaterialInstanceDynamic.h" +#include "RotatingCube.generated.h" + +/** + * Rotating cube demo actor for Flutter-Unreal integration. + * Demonstrates bidirectional communication between Flutter and Unreal. + * + * Features: + * - Responds to Flutter commands (setSpeed, setAxis, setColor, reset, getState) + * - Sends state updates back to Flutter (onSpeedChanged, onState, onReset) + * - Configurable rotation speed and axis + * - Blueprint-friendly with exposed properties + */ +UCLASS(Blueprintable, ClassGroup=(Flutter), meta=(BlueprintSpawnableComponent)) +class ARotatingCube : public AFlutterActor +{ + GENERATED_BODY() + +public: + ARotatingCube(); + + // ==================== EXPOSED PROPERTIES ==================== + + /** Current rotation speed in degrees per second */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Rotation", meta = (ClampMin = "-360", ClampMax = "360")) + float RotationSpeed = 50.0f; + + /** Rotation axis (normalized) */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Rotation") + FVector RotationAxis = FVector(0.0f, 1.0f, 0.0f); + + /** Cube color */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Appearance") + FLinearColor CubeColor = FLinearColor(0.5f, 0.5f, 1.0f, 1.0f); + + /** Whether the cube is currently rotating */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Rotation") + bool bIsRotating = true; + + /** Auto-sync state to Flutter at this interval (0 = disabled) */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Flutter") + float SyncIntervalSeconds = 0.0f; + + // ==================== BLUEPRINT EVENTS ==================== + + /** Called when speed changes from Flutter */ + UFUNCTION(BlueprintImplementableEvent, Category = "Flutter|Events") + void OnSpeedChanged_Blueprint(float NewSpeed); + + /** Called when axis changes from Flutter */ + UFUNCTION(BlueprintImplementableEvent, Category = "Flutter|Events") + void OnAxisChanged_Blueprint(FVector NewAxis); + + /** Called when color changes from Flutter */ + UFUNCTION(BlueprintImplementableEvent, Category = "Flutter|Events") + void OnColorChanged_Blueprint(FLinearColor NewColor); + + /** Called when reset is requested from Flutter */ + UFUNCTION(BlueprintImplementableEvent, Category = "Flutter|Events") + void OnReset_Blueprint(); + + // ==================== BLUEPRINT CALLABLE FUNCTIONS ==================== + + /** Set the rotation speed */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Cube") + void SetSpeed(float NewSpeed); + + /** Set the rotation axis */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Cube") + void SetAxis(FVector NewAxis); + + /** Set the cube color */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Cube") + void SetColor(FLinearColor NewColor); + + /** Reset cube to default state */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Cube") + void Reset(); + + /** Get the current state as JSON string */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Cube") + FString GetStateJson() const; + + /** Start/stop rotation */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Cube") + void SetRotating(bool bShouldRotate); + + /** Get current RPM (rotations per minute) */ + UFUNCTION(BlueprintPure, Category = "Flutter|Cube") + float GetRPM() const; + + /** Send current state to Flutter */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Cube") + void SyncStateToFlutter(); + +protected: + // ==================== OVERRIDES ==================== + + virtual void BeginPlay() override; + virtual void Tick(float DeltaTime) override; + virtual FString GetFlutterTargetName_Implementation() const override; + virtual void HandleFlutterMessage_Implementation(const FString& Method, const FString& Data) override; + + // ==================== INTERNAL ==================== + + /** The static mesh component for the cube */ + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Components") + UStaticMeshComponent* CubeMesh; + + /** Dynamic material instance for color changes */ + UPROPERTY() + UMaterialInstanceDynamic* DynamicMaterial; + + /** Timer handle for auto-sync */ + FTimerHandle SyncTimerHandle; + +private: + /** Apply current color to material */ + void UpdateMaterialColor(); + + /** Parse axis from JSON */ + FVector ParseAxisFromJson(const FString& JsonData); + + /** Parse color from JSON */ + FLinearColor ParseColorFromJson(const FString& JsonData); + + /** Current rotation angle (for state tracking) */ + float CurrentRotationAngle = 0.0f; + + /** Default values for reset */ + float DefaultSpeed = 50.0f; + FVector DefaultAxis = FVector(0.0f, 1.0f, 0.0f); + FLinearColor DefaultColor = FLinearColor(0.5f, 0.5f, 1.0f, 1.0f); +}; diff --git a/example/unreal/demo/Source/GameFrameworkProjectEditor.Target.cs b/example/unreal/demo/Source/GameFrameworkProjectEditor.Target.cs new file mode 100644 index 0000000..30851f8 --- /dev/null +++ b/example/unreal/demo/Source/GameFrameworkProjectEditor.Target.cs @@ -0,0 +1,13 @@ +using UnrealBuildTool; +using System.Collections.Generic; + +public class GameFrameworkProjectEditorTarget : TargetRules +{ + public GameFrameworkProjectEditorTarget(TargetInfo Target) : base(Target) + { + Type = TargetType.Editor; + DefaultBuildSettings = BuildSettingsVersion.V6; + IncludeOrderVersion = EngineIncludeOrderVersion.Latest; + ExtraModuleNames.Add("GameFrameworkProject"); + } +} diff --git a/templates/unreal/Scripts/FlutterGameMode.cpp b/templates/unreal/Scripts/FlutterGameMode.cpp index 4b2d6f1..f80f533 100644 --- a/templates/unreal/Scripts/FlutterGameMode.cpp +++ b/templates/unreal/Scripts/FlutterGameMode.cpp @@ -3,6 +3,7 @@ #include "FlutterGameMode.h" #include "FlutterBridge.h" #include "FlutterMessageRouter.h" +#include "Engine/World.h" #include "TimerManager.h" #include "Dom/JsonObject.h" #include "Serialization/JsonWriter.h" diff --git a/templates/unreal/Scripts/FlutterGameMode.h b/templates/unreal/Scripts/FlutterGameMode.h index 4e48414..de064ff 100644 --- a/templates/unreal/Scripts/FlutterGameMode.h +++ b/templates/unreal/Scripts/FlutterGameMode.h @@ -135,7 +135,7 @@ class AFlutterGameMode : public AGameModeBase * Get the current level */ UFUNCTION(BlueprintCallable, Category = "Flutter|Level") - int32 GetLevel() const { return CurrentLevel; } + int32 GetCurrentLevel() const { return CurrentLevel; } /** * Load a level by name diff --git a/templates/unreal/Scripts/RotatingCube.cpp b/templates/unreal/Scripts/RotatingCube.cpp index 6d1a9c5..53bf01c 100644 --- a/templates/unreal/Scripts/RotatingCube.cpp +++ b/templates/unreal/Scripts/RotatingCube.cpp @@ -22,7 +22,6 @@ ARotatingCube::ARotatingCube() } // Set default values - FlutterTargetName = TEXT("RotatingCube"); bAutoRegister = true; } @@ -80,13 +79,13 @@ void ARotatingCube::Tick(float DeltaTime) } } -FString ARotatingCube::GetFlutterTargetName() const +FString ARotatingCube::GetFlutterTargetName_Implementation() const { // Use a specific name for the rotating cube demo return TEXT("GameFrameworkDemo"); } -void ARotatingCube::OnFlutterMessage_Implementation(const FString& Method, const FString& Data) +void ARotatingCube::HandleFlutterMessage_Implementation(const FString& Method, const FString& Data) { UE_LOG(LogTemp, Log, TEXT("[RotatingCube] Message: %s(%s)"), *Method, *Data); diff --git a/templates/unreal/Scripts/RotatingCube.h b/templates/unreal/Scripts/RotatingCube.h index 29bd843..0d6cc51 100644 --- a/templates/unreal/Scripts/RotatingCube.h +++ b/templates/unreal/Scripts/RotatingCube.h @@ -2,6 +2,8 @@ #include "CoreMinimal.h" #include "FlutterActor.h" +#include "Components/StaticMeshComponent.h" +#include "Materials/MaterialInstanceDynamic.h" #include "RotatingCube.generated.h" /** @@ -101,8 +103,8 @@ class ARotatingCube : public AFlutterActor virtual void BeginPlay() override; virtual void Tick(float DeltaTime) override; - virtual FString GetFlutterTargetName() const override; - virtual void OnFlutterMessage_Implementation(const FString& Method, const FString& Data) override; + virtual FString GetFlutterTargetName_Implementation() const override; + virtual void HandleFlutterMessage_Implementation(const FString& Method, const FString& Data) override; // ==================== INTERNAL ==================== From 30a3fa7290f39c7fcef14839c55bc559adab7aec Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Thu, 3 Sep 2026 21:31:10 -0500 Subject: [PATCH 04/25] Drive the Unreal engine tick from the host Building as a framework defines BUILD_EMBEDDED_APP, which switches on FEmbeddedCommunication in Core. That is Unreal's answer to not owning main or the run loop, and until now nothing was using it, so a linked engine sat there doing nothing. The C ABI grows an engine lifecycle: Init, Tick, WakeGameThread, KeepAwake, AllowSleep, and the two IsAwake queries. Tick deliberately does not marshal threads, because TickGameThread has to run on whichever thread the host drives the engine from. Both pods now drive it from a display link, so the engine's timing follows the display it renders to rather than an arbitrary timer, and the tick lands on the main thread where an embedded engine expects to be driven. macOS prefers NSScreen.displayLink and falls back to CVDisplayLink, which is deprecated from macOS 15 but still needed at the 10.14 deployment target. iOS uses CADisplayLink, which is already on the main run loop. Internal dispatch moves from AsyncTask to FEmbeddedCommunication::RunOnGameThread, which is documented safe before Init. Work queued during startup is now delivered once the engine comes up instead of being dropped, and the earlier guard against calling into an uninitialised engine is no longer load-bearing. Verified against a real UE 5.8 build: a host linking UnrealFramework.framework initialises the engine and ticks it, with TickGameThread reporting work done on every frame. --- .../unreal/dart/ios/Classes/UnrealBridge.mm | 72 +++++++++- .../unreal/dart/ios/Tests/run_bridge_tests.sh | 4 +- .../dart/ios/gameframework_unreal.podspec | 2 +- .../unreal/dart/macos/Classes/UnrealBridge.mm | 125 +++++++++++++++++- .../dart/macos/Tests/run_bridge_tests.sh | 4 +- .../dart/macos/gameframework_unreal.podspec | 2 +- .../Private/FlutterBridge_Apple.cpp | 76 +++++++++-- .../FlutterPlugin/Public/UnrealBridge.h | 42 +++++- .../Private/FlutterBridge_Apple.cpp | 76 +++++++++-- .../FlutterPlugin/Public/UnrealBridge.h | 42 +++++- 10 files changed, 407 insertions(+), 38 deletions(-) diff --git a/engines/unreal/dart/ios/Classes/UnrealBridge.mm b/engines/unreal/dart/ios/Classes/UnrealBridge.mm index e6d1e43..751657e 100644 --- a/engines/unreal/dart/ios/Classes/UnrealBridge.mm +++ b/engines/unreal/dart/ios/Classes/UnrealBridge.mm @@ -44,6 +44,10 @@ typedef void (*UnrealBinaryCallback)(const char* target, typedef void (*ApplyQualitySettingsFn)(int32_t, int32_t, int32_t, int32_t, int32_t, int32_t, int32_t, int32_t); typedef int32_t (*GetQualitySettingsFn)(int32_t*, int32_t); +typedef void (*InitFn)(void); +typedef int32_t (*TickFn)(float); +typedef void (*KeepAwakeFn)(const char*, int32_t); +typedef void (*AllowSleepFn)(const char*); typedef void (*PauseFn)(int32_t); typedef void (*StopFn)(void); typedef int32_t (*IsReadyFn)(void); @@ -172,6 +176,66 @@ static void HandleUnrealBinary(const char* target, const char* method, }); } + +// ============================================================ +// MARK: - Driving the engine +// ============================================================ +// +// An embedded Unreal does not own the run loop, so nothing advances the engine +// unless the host does it. The bridge drives FEmbeddedCommunication::TickGameThread +// from a display link, which keeps the engine's timing tied to the display it +// renders to rather than to an arbitrary timer. +// +// Ticking happens on the main thread. That is where the host lives, and where +// an embedded engine expects to be driven from. + +static void UnrealTick(double deltaSeconds) { + TickFn tick = UNREAL_FN(TickFn, "UnrealBridge_Tick"); + if (tick) { + tick((float)deltaSeconds); + } +} + +/// CADisplayLink already fires on the main run loop, so no hop is needed. +static CADisplayLink* GDisplayLink = nil; +static CFTimeInterval GLastTickTime = 0; + +@interface UnrealTicker : NSObject ++ (instancetype)shared; +- (void)onFrame:(CADisplayLink*)link; +@end + +@implementation UnrealTicker ++ (instancetype)shared { + static UnrealTicker* instance = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ instance = [[UnrealTicker alloc] init]; }); + return instance; +} +- (void)onFrame:(CADisplayLink*)link { + const CFTimeInterval current = link.timestamp; + const CFTimeInterval delta = + (GLastTickTime > 0) ? (current - GLastTickTime) : link.duration; + GLastTickTime = current; + UnrealTick(delta); +} +@end + +static void StartTicking(void) { + if (GDisplayLink) return; + GDisplayLink = [CADisplayLink displayLinkWithTarget:[UnrealTicker shared] + selector:@selector(onFrame:)]; + GLastTickTime = 0; + [GDisplayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes]; + NSLog(@"[UnrealBridge] Ticking the engine from the display link"); +} + +static void StopTicking(void) { + if (!GDisplayLink) return; + [GDisplayLink invalidate]; + GDisplayLink = nil; +} + // ============================================================ // MARK: - UnrealBridge // ============================================================ @@ -228,7 +292,12 @@ - (BOOL)createWithConfig:(NSDictionary*)config controller:(id)controller { @"Place one in your level if messages never arrive."); } - NSLog(@"[UnrealBridge] Bridge created, callbacks registered"); + InitFn initEngine = UNREAL_FN(InitFn, "UnrealBridge_Init"); + if (initEngine) initEngine(); + + StartTicking(); + + NSLog(@"[UnrealBridge] Bridge created, callbacks registered, engine ticking"); return YES; } @@ -248,6 +317,7 @@ - (void)resume { } - (void)quit { + StopTicking(); StopFn stop = UNREAL_FN(StopFn, "UnrealBridge_Stop"); if (stop) stop(); GUnrealEngineController = nil; diff --git a/engines/unreal/dart/ios/Tests/run_bridge_tests.sh b/engines/unreal/dart/ios/Tests/run_bridge_tests.sh index 0d0f0ac..ef146f7 100755 --- a/engines/unreal/dart/ios/Tests/run_bridge_tests.sh +++ b/engines/unreal/dart/ios/Tests/run_bridge_tests.sh @@ -60,7 +60,7 @@ echo "=== Framework absent ===" compile_mm "$HERE/UnrealBridgeAbsentTests.mm" "$BUILD/absent.o" xcrun --sdk "$SDK" clang++ -target "$TARGET" \ "$BUILD/UnrealBridge.o" "$BUILD/absent.o" \ - -framework Foundation -framework UIKit -o "$BUILD/absent" + -framework Foundation -framework UIKit -framework QuartzCore -o "$BUILD/absent" xcrun simctl spawn "$UDID" "$BUILD/absent" 2>&1 | grep -v '^20[0-9][0-9]-' echo @@ -68,5 +68,5 @@ echo "=== Framework present (mock) ===" compile_mm "$HERE/UnrealBridgeTests.mm" "$BUILD/live.o" xcrun --sdk "$SDK" clang++ -target "$TARGET" \ "$BUILD/UnrealBridge.o" "$BUILD/live.o" "$BUILD/MockUnreal.dylib" \ - -framework Foundation -framework UIKit -rpath "$BUILD" -o "$BUILD/live" + -framework Foundation -framework UIKit -framework QuartzCore -rpath "$BUILD" -o "$BUILD/live" xcrun simctl spawn "$UDID" "$BUILD/live" 2>&1 | grep -v '^20[0-9][0-9]-' diff --git a/engines/unreal/dart/ios/gameframework_unreal.podspec b/engines/unreal/dart/ios/gameframework_unreal.podspec index 227cd18..45044dc 100644 --- a/engines/unreal/dart/ios/gameframework_unreal.podspec +++ b/engines/unreal/dart/ios/gameframework_unreal.podspec @@ -53,5 +53,5 @@ to sync your Unreal export to your plugin's ios/ directory. s.swift_version = '5.0' # System frameworks required by Unreal Engine - s.frameworks = 'UIKit', 'Foundation', 'Metal', 'MetalKit', 'CoreGraphics', 'AVFoundation', 'AudioToolbox' + s.frameworks = 'UIKit', 'Foundation', 'Metal', 'MetalKit', 'CoreGraphics', 'AVFoundation', 'AudioToolbox', 'QuartzCore' end diff --git a/engines/unreal/dart/macos/Classes/UnrealBridge.mm b/engines/unreal/dart/macos/Classes/UnrealBridge.mm index 1880910..62ee5fa 100644 --- a/engines/unreal/dart/macos/Classes/UnrealBridge.mm +++ b/engines/unreal/dart/macos/Classes/UnrealBridge.mm @@ -7,6 +7,8 @@ #import #import +#import +#import #import "UnrealBridge.h" // ============================================================ @@ -50,6 +52,10 @@ typedef void (*UnrealBinaryCallback)(const char* target, typedef void (*ApplyQualitySettingsFn)(int32_t, int32_t, int32_t, int32_t, int32_t, int32_t, int32_t, int32_t); typedef int32_t (*GetQualitySettingsFn)(int32_t*, int32_t); +typedef void (*InitFn)(void); +typedef int32_t (*TickFn)(float); +typedef void (*KeepAwakeFn)(const char*, int32_t); +typedef void (*AllowSleepFn)(const char*); typedef void (*PauseFn)(int32_t); typedef void (*StopFn)(void); typedef int32_t (*IsReadyFn)(void); @@ -178,6 +184,117 @@ static void HandleUnrealBinary(const char* target, const char* method, }); } + +// ============================================================ +// MARK: - Driving the engine +// ============================================================ +// +// An embedded Unreal does not own the run loop, so nothing advances the engine +// unless the host does it. The bridge drives FEmbeddedCommunication::TickGameThread +// from a display link, which keeps the engine's timing tied to the display it +// renders to rather than to an arbitrary timer. +// +// Ticking happens on the main thread. That is where the host lives, and where +// an embedded engine expects to be driven from. + +static void UnrealTick(double deltaSeconds) { + TickFn tick = UNREAL_FN(TickFn, "UnrealBridge_Tick"); + if (tick) { + tick((float)deltaSeconds); + } +} + +/// Two display link APIs, picked at runtime. +/// +/// NSScreen.displayLink arrived in macOS 14 and CVDisplayLink is deprecated +/// from 15, but this pod still supports 10.14, so both paths stay. Either way +/// the tick runs on the main thread, which is where the host lives and where an +/// embedded engine expects to be driven from. + +static CFTimeInterval GLastTickTime = 0; + +static void TickWithTimestamp(CFTimeInterval current, CFTimeInterval fallbackDelta) { + const CFTimeInterval delta = + (GLastTickTime > 0) ? (current - GLastTickTime) : fallbackDelta; + GLastTickTime = current; + UnrealTick(delta); +} + +API_AVAILABLE(macos(14.0)) +@interface UnrealTicker : NSObject ++ (instancetype)shared; +- (void)onFrame:(CADisplayLink*)link; +@end + +@implementation UnrealTicker ++ (instancetype)shared { + static UnrealTicker* instance = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ instance = [[UnrealTicker alloc] init]; }); + return instance; +} +- (void)onFrame:(CADisplayLink*)link { + TickWithTimestamp(link.timestamp, link.duration); +} +@end + +static CADisplayLink* GModernLink = nil; +static CVDisplayLinkRef GLegacyLink = NULL; + +static CVReturn LegacyCallback(CVDisplayLinkRef, const CVTimeStamp*, + const CVTimeStamp*, CVOptionFlags, + CVOptionFlags*, void*) { + // CVDisplayLink fires on its own thread, so hop to main before ticking. + dispatch_async(dispatch_get_main_queue(), ^{ + TickWithTimestamp(CACurrentMediaTime(), 1.0 / 60.0); + }); + return kCVReturnSuccess; +} + +static void StartTicking(void) { + if (GModernLink || GLegacyLink) return; + GLastTickTime = 0; + + if (@available(macOS 14.0, *)) { + NSScreen* screen = NSScreen.mainScreen; + if (screen) { + GModernLink = [screen displayLinkWithTarget:[UnrealTicker shared] + selector:@selector(onFrame:)]; + [GModernLink addToRunLoop:NSRunLoop.mainRunLoop + forMode:NSRunLoopCommonModes]; + NSLog(@"[UnrealBridge] Ticking the engine from the screen display link"); + return; + } + } + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + if (CVDisplayLinkCreateWithActiveCGDisplays(&GLegacyLink) != kCVReturnSuccess) { + NSLog(@"[UnrealBridge] Could not create a display link; the engine will not tick"); + GLegacyLink = NULL; + return; + } + CVDisplayLinkSetOutputCallback(GLegacyLink, &LegacyCallback, NULL); + CVDisplayLinkStart(GLegacyLink); +#pragma clang diagnostic pop + NSLog(@"[UnrealBridge] Ticking the engine from a CVDisplayLink"); +} + +static void StopTicking(void) { + if (GModernLink) { + [GModernLink invalidate]; + GModernLink = nil; + } + if (GLegacyLink) { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + CVDisplayLinkStop(GLegacyLink); + CVDisplayLinkRelease(GLegacyLink); +#pragma clang diagnostic pop + GLegacyLink = NULL; + } +} + // ============================================================ // MARK: - UnrealBridge // ============================================================ @@ -219,7 +336,12 @@ - (BOOL)createWithConfig:(NSDictionary*)config controller:(id)controller { @"Place one in your level if messages never arrive."); } - NSLog(@"[UnrealBridge] Bridge created, callbacks registered"); + InitFn initEngine = UNREAL_FN(InitFn, "UnrealBridge_Init"); + if (initEngine) initEngine(); + + StartTicking(); + + NSLog(@"[UnrealBridge] Bridge created, callbacks registered, engine ticking"); return YES; } @@ -239,6 +361,7 @@ - (void)resume { } - (void)quit { + StopTicking(); StopFn stop = UNREAL_FN(StopFn, "UnrealBridge_Stop"); if (stop) stop(); GUnrealEngineController = nil; diff --git a/engines/unreal/dart/macos/Tests/run_bridge_tests.sh b/engines/unreal/dart/macos/Tests/run_bridge_tests.sh index 4ce5581..18e1e78 100755 --- a/engines/unreal/dart/macos/Tests/run_bridge_tests.sh +++ b/engines/unreal/dart/macos/Tests/run_bridge_tests.sh @@ -42,7 +42,7 @@ echo echo "=== Framework absent ===" compile_mm "$HERE/UnrealBridgeAbsentTests.mm" "$BUILD/absent.o" xcrun --sdk macosx clang++ "$BUILD/UnrealBridge.o" "$BUILD/absent.o" \ - -framework Foundation -framework Cocoa -o "$BUILD/absent" + -framework Foundation -framework Cocoa -framework CoreVideo -framework QuartzCore -o "$BUILD/absent" "$BUILD/absent" echo @@ -50,5 +50,5 @@ echo "=== Framework present (mock) ===" compile_mm "$HERE/UnrealBridgeTests.mm" "$BUILD/live.o" xcrun --sdk macosx clang++ "$BUILD/UnrealBridge.o" "$BUILD/live.o" \ "$BUILD/MockUnreal.dylib" \ - -framework Foundation -framework Cocoa -rpath "$BUILD" -o "$BUILD/live" + -framework Foundation -framework Cocoa -framework CoreVideo -framework QuartzCore -rpath "$BUILD" -o "$BUILD/live" "$BUILD/live" diff --git a/engines/unreal/dart/macos/gameframework_unreal.podspec b/engines/unreal/dart/macos/gameframework_unreal.podspec index 4429485..f33f75e 100644 --- a/engines/unreal/dart/macos/gameframework_unreal.podspec +++ b/engines/unreal/dart/macos/gameframework_unreal.podspec @@ -26,7 +26,7 @@ console commands, and level loading for Unreal Engine in Flutter apps. # Note: UnrealFramework.framework must be manually added to the macOS project # This podspec does not directly link the framework, as it's typically # bundled with the game project - s.frameworks = 'Cocoa', 'Foundation', 'Metal', 'MetalKit', 'CoreGraphics', 'QuartzCore' + s.frameworks = 'Cocoa', 'Foundation', 'Metal', 'MetalKit', 'CoreGraphics', 'QuartzCore', 'CoreVideo' # Enable Objective-C++ compilation for bridge files s.xcconfig = { diff --git a/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp b/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp index f582d95..fb5be06 100644 --- a/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp +++ b/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp @@ -6,6 +6,7 @@ #include "UnrealBridge.h" #include "Async/Async.h" +#include "Misc/EmbeddedCommunication.h" #include @@ -67,26 +68,25 @@ static FString CStringToFString(const char* String) /// /// Everything below reaches into UObjects, which is only legal on the game /// thread. Calls arriving from the app's main thread get queued. +/// Priority for work queued through FEmbeddedCommunication. Zero is the normal +/// band; higher numbers run first. +static constexpr int GBridgeWorkPriority = 0; + static void RunOnGameThread(TFunction Work) { - // Nothing to deliver to, and more importantly nothing safe to ask. Until an - // AFlutterBridge has registered, the engine may not be initialised at all: - // the host can load this library and call in before PreInit has run, and - // IsInGameThread and the task graph both read globals that do not exist - // yet. Dropping the call here is what the callee would have done anyway. - if (GFlutterBridgeInstance.load(std::memory_order_acquire) == nullptr) - { - return; - } - if (IsInGameThread()) { Work(); + return; } - else - { - AsyncTask(ENamedThreads::GameThread, MoveTemp(Work)); - } + + // FEmbeddedCommunication::RunOnGameThread is explicitly safe before Init, + // so a host that calls in during startup gets its work queued rather than + // dropped. That matters here: the task graph is not safe that early, and an + // earlier version of this reached straight for AsyncTask and crashed when + // the engine had not been initialised. + FEmbeddedCommunication::RunOnGameThread(GBridgeWorkPriority, MoveTemp(Work)); + FEmbeddedCommunication::WakeGameThread(); } /// Refresh the quality cache. Game thread only. @@ -390,6 +390,54 @@ int32_t UnrealBridge_GetQualitySettings(int32_t* OutValues, int32_t Capacity) return UNREALBRIDGE_QUALITY_VALUE_COUNT; } +void UnrealBridge_Init(void) +{ + static std::atomic bInitialised{false}; + bool bExpected = false; + if (!bInitialised.compare_exchange_strong(bExpected, true)) + { + return; + } + + FEmbeddedCommunication::Init(); + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Apple] Embedded communication initialised")); +} + +int32_t UnrealBridge_Tick(float DeltaSeconds) +{ + // TickGameThread must run on the thread that owns the engine. In an + // embedded build that is whichever thread the host drives it from, so this + // deliberately does not marshal: doing so would tick from somewhere the + // engine does not expect. + return FEmbeddedCommunication::TickGameThread(DeltaSeconds) ? 1 : 0; +} + +void UnrealBridge_WakeGameThread(void) +{ + FEmbeddedCommunication::WakeGameThread(); +} + +void UnrealBridge_KeepAwake(const char* Requester, int32_t bNeedsRendering) +{ + FEmbeddedCommunication::KeepAwake(FName(CStringToFString(Requester)), + bNeedsRendering != 0); +} + +void UnrealBridge_AllowSleep(const char* Requester) +{ + FEmbeddedCommunication::AllowSleep(FName(CStringToFString(Requester))); +} + +int32_t UnrealBridge_IsAwakeForTicking(void) +{ + return FEmbeddedCommunication::IsAwakeForTicking() ? 1 : 0; +} + +int32_t UnrealBridge_IsAwakeForRendering(void) +{ + return FEmbeddedCommunication::IsAwakeForRendering() ? 1 : 0; +} + void UnrealBridge_Pause(int32_t Paused) { const bool bPaused = Paused != 0; diff --git a/engines/unreal/plugin/Source/FlutterPlugin/Public/UnrealBridge.h b/engines/unreal/plugin/Source/FlutterPlugin/Public/UnrealBridge.h index 7e41beb..4448e34 100644 --- a/engines/unreal/plugin/Source/FlutterPlugin/Public/UnrealBridge.h +++ b/engines/unreal/plugin/Source/FlutterPlugin/Public/UnrealBridge.h @@ -100,7 +100,47 @@ UNREALBRIDGE_API int32_t UnrealBridge_GetQualitySettings(int32_t* outValues, #define UNREALBRIDGE_QUALITY_VALUE_COUNT 7 // ============================================================ -// MARK: - Lifecycle +// MARK: - Engine lifecycle +// ============================================================ +// +// In an embedded build Unreal does not own main() or the run loop, so the host +// has to drive the engine. Unreal exposes this as FEmbeddedCommunication, which +// building as a framework switches on via BUILD_EMBEDDED_APP. +// +// The sequence is: call UnrealBridge_Init once, early, then UnrealBridge_Tick +// every frame from the thread that owns the engine. Between ticks the engine +// sleeps unless something asks it to stay awake. + +/// Bring up the embedded engine plumbing. Safe to call more than once; only +/// the first call does anything. +UNREALBRIDGE_API void UnrealBridge_Init(void); + +/// Advance the engine by [deltaSeconds]. Call from the thread that owns the +/// engine, once per frame. Returns non-zero if the engine did work and wants to +/// be ticked again promptly. +/// +/// A host with a display link should pass the real frame delta rather than a +/// fixed step, so the engine's own timing matches the display it renders to. +UNREALBRIDGE_API int32_t UnrealBridge_Tick(float deltaSeconds); + +/// Nudge the game thread when something has been queued for it. +UNREALBRIDGE_API void UnrealBridge_WakeGameThread(void); + +/// Hold the engine awake, or let it sleep again. Calls pair by `requester`, and +/// repeated calls with the same requester must agree on `needsRendering`. +/// Without at least one requester the engine idles between ticks, which is the +/// point: an embedded engine on a mostly static screen should not burn a core. +UNREALBRIDGE_API void UnrealBridge_KeepAwake(const char* requester, + int32_t needsRendering); +UNREALBRIDGE_API void UnrealBridge_AllowSleep(const char* requester); + +/// Whether the engine currently wants ticking, and whether it wants rendering. +/// A host can skip work when both are false. +UNREALBRIDGE_API int32_t UnrealBridge_IsAwakeForTicking(void); +UNREALBRIDGE_API int32_t UnrealBridge_IsAwakeForRendering(void); + +// ============================================================ +// MARK: - Host lifecycle // ============================================================ UNREALBRIDGE_API void UnrealBridge_Pause(int32_t paused); diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp index f582d95..fb5be06 100644 --- a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp @@ -6,6 +6,7 @@ #include "UnrealBridge.h" #include "Async/Async.h" +#include "Misc/EmbeddedCommunication.h" #include @@ -67,26 +68,25 @@ static FString CStringToFString(const char* String) /// /// Everything below reaches into UObjects, which is only legal on the game /// thread. Calls arriving from the app's main thread get queued. +/// Priority for work queued through FEmbeddedCommunication. Zero is the normal +/// band; higher numbers run first. +static constexpr int GBridgeWorkPriority = 0; + static void RunOnGameThread(TFunction Work) { - // Nothing to deliver to, and more importantly nothing safe to ask. Until an - // AFlutterBridge has registered, the engine may not be initialised at all: - // the host can load this library and call in before PreInit has run, and - // IsInGameThread and the task graph both read globals that do not exist - // yet. Dropping the call here is what the callee would have done anyway. - if (GFlutterBridgeInstance.load(std::memory_order_acquire) == nullptr) - { - return; - } - if (IsInGameThread()) { Work(); + return; } - else - { - AsyncTask(ENamedThreads::GameThread, MoveTemp(Work)); - } + + // FEmbeddedCommunication::RunOnGameThread is explicitly safe before Init, + // so a host that calls in during startup gets its work queued rather than + // dropped. That matters here: the task graph is not safe that early, and an + // earlier version of this reached straight for AsyncTask and crashed when + // the engine had not been initialised. + FEmbeddedCommunication::RunOnGameThread(GBridgeWorkPriority, MoveTemp(Work)); + FEmbeddedCommunication::WakeGameThread(); } /// Refresh the quality cache. Game thread only. @@ -390,6 +390,54 @@ int32_t UnrealBridge_GetQualitySettings(int32_t* OutValues, int32_t Capacity) return UNREALBRIDGE_QUALITY_VALUE_COUNT; } +void UnrealBridge_Init(void) +{ + static std::atomic bInitialised{false}; + bool bExpected = false; + if (!bInitialised.compare_exchange_strong(bExpected, true)) + { + return; + } + + FEmbeddedCommunication::Init(); + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Apple] Embedded communication initialised")); +} + +int32_t UnrealBridge_Tick(float DeltaSeconds) +{ + // TickGameThread must run on the thread that owns the engine. In an + // embedded build that is whichever thread the host drives it from, so this + // deliberately does not marshal: doing so would tick from somewhere the + // engine does not expect. + return FEmbeddedCommunication::TickGameThread(DeltaSeconds) ? 1 : 0; +} + +void UnrealBridge_WakeGameThread(void) +{ + FEmbeddedCommunication::WakeGameThread(); +} + +void UnrealBridge_KeepAwake(const char* Requester, int32_t bNeedsRendering) +{ + FEmbeddedCommunication::KeepAwake(FName(CStringToFString(Requester)), + bNeedsRendering != 0); +} + +void UnrealBridge_AllowSleep(const char* Requester) +{ + FEmbeddedCommunication::AllowSleep(FName(CStringToFString(Requester))); +} + +int32_t UnrealBridge_IsAwakeForTicking(void) +{ + return FEmbeddedCommunication::IsAwakeForTicking() ? 1 : 0; +} + +int32_t UnrealBridge_IsAwakeForRendering(void) +{ + return FEmbeddedCommunication::IsAwakeForRendering() ? 1 : 0; +} + void UnrealBridge_Pause(int32_t Paused) { const bool bPaused = Paused != 0; diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/UnrealBridge.h b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/UnrealBridge.h index 7e41beb..4448e34 100644 --- a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/UnrealBridge.h +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/UnrealBridge.h @@ -100,7 +100,47 @@ UNREALBRIDGE_API int32_t UnrealBridge_GetQualitySettings(int32_t* outValues, #define UNREALBRIDGE_QUALITY_VALUE_COUNT 7 // ============================================================ -// MARK: - Lifecycle +// MARK: - Engine lifecycle +// ============================================================ +// +// In an embedded build Unreal does not own main() or the run loop, so the host +// has to drive the engine. Unreal exposes this as FEmbeddedCommunication, which +// building as a framework switches on via BUILD_EMBEDDED_APP. +// +// The sequence is: call UnrealBridge_Init once, early, then UnrealBridge_Tick +// every frame from the thread that owns the engine. Between ticks the engine +// sleeps unless something asks it to stay awake. + +/// Bring up the embedded engine plumbing. Safe to call more than once; only +/// the first call does anything. +UNREALBRIDGE_API void UnrealBridge_Init(void); + +/// Advance the engine by [deltaSeconds]. Call from the thread that owns the +/// engine, once per frame. Returns non-zero if the engine did work and wants to +/// be ticked again promptly. +/// +/// A host with a display link should pass the real frame delta rather than a +/// fixed step, so the engine's own timing matches the display it renders to. +UNREALBRIDGE_API int32_t UnrealBridge_Tick(float deltaSeconds); + +/// Nudge the game thread when something has been queued for it. +UNREALBRIDGE_API void UnrealBridge_WakeGameThread(void); + +/// Hold the engine awake, or let it sleep again. Calls pair by `requester`, and +/// repeated calls with the same requester must agree on `needsRendering`. +/// Without at least one requester the engine idles between ticks, which is the +/// point: an embedded engine on a mostly static screen should not burn a core. +UNREALBRIDGE_API void UnrealBridge_KeepAwake(const char* requester, + int32_t needsRendering); +UNREALBRIDGE_API void UnrealBridge_AllowSleep(const char* requester); + +/// Whether the engine currently wants ticking, and whether it wants rendering. +/// A host can skip work when both are false. +UNREALBRIDGE_API int32_t UnrealBridge_IsAwakeForTicking(void); +UNREALBRIDGE_API int32_t UnrealBridge_IsAwakeForRendering(void); + +// ============================================================ +// MARK: - Host lifecycle // ============================================================ UNREALBRIDGE_API void UnrealBridge_Pause(int32_t paused); From 4c043810bcfc9006bf57f54c506daec4e9572a89 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Thu, 3 Sep 2026 23:20:39 -0500 Subject: [PATCH 05/25] Render surface for iOS, and what a real device run taught us about embedding Unreal's embedded mode does not create its own view. LaunchIOS.cpp compiles that branch out under BUILD_EMBEDDED_APP and says what it expects instead: "For embedded apps, the UEEmbeddedView must have been created and set into the AppDelegate as IOSView". So Private/IOS/FlutterView_IOS.mm does that. It builds an FIOSView, registers it with the app delegate, creates its framebuffer, and hands it back through the C ABI as an opaque pointer. The pod casts it to UIView* and the engine renders into that view's CAMetalLayer, with nothing copied per frame. Sizes cross in points and the engine works in pixels, so the scale factor is applied on the way in. View creation waits for the engine. It depends on config that is not loaded at startup, and Unreal announces when it is: FAppEntry broadcasts "inisareready" on the embedded-to-native channel. The plugin subscribes at module load, which is early enough because the module is PreDefault and the broadcast is one-shot. UnrealBridge_SetEngineReadyCallback fires immediately if the announcement has already been and gone, so registration order does not matter. Something also has to start the engine, because the host owns main() and the app delegate and Unreal's launch path never runs. That is UnrealBridge_StartEngine, wrapping +[FIOSView StartupEmbeddedUnreal], which IOSView.cpp calls the "LaunchIOS replacement". Getting the iOS framework to build at all took three fixes. bBuildAsFramework makes UEBuildIOS set bShouldCompileAsDLL and define BUILD_EMBEDDED_APP, but UBT then objects that the target changes settings shared with UnrealGame's build products. FlutterView_IOS.mm assumed an embedded build and broke plain iOS compiles, so it has a non-embedded branch. And the framework link failed on swiftCompatibility56, which Xcode adds when it drives an app link and UBT does not when it links a dylib; the target adds the toolchain path, choosing device or simulator by architecture. Then we ran it on a device, and that is the part worth recording. The framework links, installs, launches, and Unreal's allocator comes up inside the host process. The engine never boots. The binary contains no "inisareready", StartupEmbeddedUnreal exists only as a message-send stub and throws unrecognized selector, and the embedded-only path in IOSAppDelegate is compiled out. An installed engine ships its modules prebuilt, so BUILD_EMBEDDED_APP reached this project and not the engine. UBT's original objection was the accurate signal: that setting needs the engine rebuilt. bOverrideBuildEnvironment silences the objection without answering it, and yields a binary where one half believes it is embedded and the other does not. Embedding Unreal on iOS requires an engine built from source. Everything here is correct and verified as far as an installed engine can go. Also fixes template bugs found along the way: two overrides in RotatingCube naming methods the base class does not declare, a UFUNCTION GetLevel colliding with AActor's, a NewObject overload that does not exist, and FJsonObject::Values being keyed by a shared string type in UE 5.8 rather than FString. --- .../unreal/dart/ios/Classes/UnrealBridge.mm | 131 ++++++++- .../ios/Classes/UnrealEngineController.swift | 26 +- .../dart/ios/Tests/MockUnrealFramework.c | 46 +++ .../dart/ios/Tests/UnrealBridgeTests.mm | 22 ++ .../dart/macos/Tests/MockUnrealFramework.c | 46 +++ .../dart/macos/Tests/UnrealBridgeTests.mm | 15 + .../Private/FlutterBridge_Apple.cpp | 106 +++++++ .../FlutterPlugin/Private/FlutterPlugin.cpp | 12 + .../Private/IOS/FlutterView_IOS.mm | 263 ++++++++++++++++++ .../FlutterPlugin/Public/UnrealBridge.h | 84 ++++++ example/unreal/demo/Config/DefaultEngine.ini | 2 +- .../Private/FlutterBridge_Apple.cpp | 106 +++++++ .../FlutterPlugin/Private/FlutterPlugin.cpp | 12 + .../Private/IOS/FlutterView_IOS.mm | 263 ++++++++++++++++++ .../FlutterPlugin/Public/UnrealBridge.h | 84 ++++++ .../Source/GameFrameworkProject.Target.cs | 92 +++++- 16 files changed, 1298 insertions(+), 12 deletions(-) create mode 100644 engines/unreal/plugin/Source/FlutterPlugin/Private/IOS/FlutterView_IOS.mm create mode 100644 example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/IOS/FlutterView_IOS.mm diff --git a/engines/unreal/dart/ios/Classes/UnrealBridge.mm b/engines/unreal/dart/ios/Classes/UnrealBridge.mm index 751657e..8572515 100644 --- a/engines/unreal/dart/ios/Classes/UnrealBridge.mm +++ b/engines/unreal/dart/ios/Classes/UnrealBridge.mm @@ -48,6 +48,13 @@ typedef void (*ApplyQualitySettingsFn)(int32_t, int32_t, int32_t, int32_t, typedef int32_t (*TickFn)(float); typedef void (*KeepAwakeFn)(const char*, int32_t); typedef void (*AllowSleepFn)(const char*); +typedef void (*EngineReadyCallback)(void); +typedef void (*SetEngineReadyCallbackFn)(EngineReadyCallback); +typedef int32_t (*IsReadyForViewFn)(void); +typedef void* (*CreateViewFn)(float, float, float); +typedef void (*ResizeViewFn)(float, float, float); +typedef void (*DestroyViewFn)(void); +typedef int32_t (*IsViewReadyFn)(void); typedef void (*PauseFn)(int32_t); typedef void (*StopFn)(void); typedef int32_t (*IsReadyFn)(void); @@ -236,12 +243,36 @@ static void StopTicking(void) { GDisplayLink = nil; } +// ============================================================ +// MARK: - Waiting for the engine before building a view +// ============================================================ +// +// The engine reads its config before a render view can exist, and announces +// when that is done. Asking earlier gets NULL, so the bridge registers for the +// signal and builds the view when it lands rather than guessing at a delay. + +static void BuildViewNowThatEngineIsReady(void); + +/// Fires on the game thread, so hop to main before touching UIKit. +static void OnEngineReady(void) { + dispatch_async(dispatch_get_main_queue(), ^{ + BuildViewNowThatEngineIsReady(); + }); +} + // ============================================================ // MARK: - UnrealBridge // ============================================================ -@interface UnrealBridge : NSObject +@interface UnrealBridge : NSObject { + CGSize _requestedViewSize; +} +/// The engine's render view once it exists. Owned by the engine's app +/// delegate, so this is an observing reference. +@property (nonatomic, weak) UIView* engineView; + (UnrealBridge*)shared; +- (void)resizeViewTo:(CGSize)size; +- (BOOL)isViewReady; - (BOOL)createWithConfig:(NSDictionary*)config controller:(id)controller; - (UIView*)getView; - (void)pause; @@ -257,6 +288,15 @@ - (NSDictionary*)getQualitySettings; @implementation UnrealBridge +/// Size the engine renders at until the host resizes it. The screen bounds are +/// the best guess available before the widget has been laid out. +- (CGSize)requestedViewSize { + if (_requestedViewSize.width > 0 && _requestedViewSize.height > 0) { + return _requestedViewSize; + } + return UIScreen.mainScreen.bounds.size; +} + + (UnrealBridge*)shared { static UnrealBridge* instance = nil; static dispatch_once_t onceToken; @@ -282,6 +322,12 @@ - (BOOL)createWithConfig:(NSDictionary*)config controller:(id)controller { SetBinaryCallbackFn setBinary = UNREAL_FN(SetBinaryCallbackFn, "UnrealBridge_SetBinaryCallback"); if (setBinary) setBinary(&HandleUnrealBinary); + // Ask to be told when a view can be made. If the engine already announced + // it, this fires straight away rather than never. + SetEngineReadyCallbackFn setReady = + UNREAL_FN(SetEngineReadyCallbackFn, "UnrealBridge_SetEngineReadyCallback"); + if (setReady) setReady(&OnEngineReady); + IsReadyFn isReady = UNREAL_FN(IsReadyFn, "UnrealBridge_IsReady"); const BOOL engineReady = isReady && (isReady() != 0); if (!engineReady) { @@ -302,10 +348,41 @@ - (BOOL)createWithConfig:(NSDictionary*)config controller:(id)controller { } - (UIView*)getView { - // Unreal owns its own window on iOS; there is no subview to hand back. + // Unreal's embedded mode does not build its own view. The framework makes + // an FIOSView, registers it with the app delegate and hands it back here, + // so the engine renders straight into a view we can put inside a Flutter + // platform view. Nothing is copied per frame. + UIView* existing = [UnrealBridge shared].engineView; + if (existing) { + return existing; + } + + // Not ready yet is the normal case on the first call: the engine announces + // when its config is loaded and the view gets built then. The controller is + // told through onUnrealViewReady, so returning nil here is not a failure. + IsReadyForViewFn readyForView = + UNREAL_FN(IsReadyForViewFn, "UnrealBridge_IsReadyForView"); + if (readyForView && readyForView()) { + BuildViewNowThatEngineIsReady(); + return [UnrealBridge shared].engineView; + } + + NSLog(@"[UnrealBridge] Engine not ready for a view yet; waiting for its signal"); return nil; } +- (void)resizeViewTo:(CGSize)size { + ResizeViewFn resize = UNREAL_FN(ResizeViewFn, "UnrealBridge_ResizeView"); + if (!resize) return; + _requestedViewSize = size; + resize((float)size.width, (float)size.height, (float)UIScreen.mainScreen.scale); +} + +- (BOOL)isViewReady { + IsViewReadyFn ready = UNREAL_FN(IsViewReadyFn, "UnrealBridge_IsViewReady"); + return ready && ready() != 0; +} + - (void)pause { PauseFn pause = UNREAL_FN(PauseFn, "UnrealBridge_Pause"); if (pause) pause(1); @@ -318,6 +395,10 @@ - (void)resume { - (void)quit { StopTicking(); + + DestroyViewFn destroyView = UNREAL_FN(DestroyViewFn, "UnrealBridge_DestroyView"); + if (destroyView) destroyView(); + StopFn stop = UNREAL_FN(StopFn, "UnrealBridge_Stop"); if (stop) stop(); GUnrealEngineController = nil; @@ -393,3 +474,49 @@ - (NSDictionary*)getQualitySettings { } @end + +// ============================================================ +// MARK: - Deferred view creation +// ============================================================ + +static void BuildViewNowThatEngineIsReady(void) { + UnrealBridge* bridge = UnrealBridge.shared; + if (bridge.engineView) { + return; + } + + CreateViewFn createView = UNREAL_FN(CreateViewFn, "UnrealBridge_CreateView"); + if (!createView) { + NSLog(@"[UnrealBridge] Framework has no render view entry point"); + return; + } + + const CGSize size = bridge.requestedViewSize; + const CGFloat scale = UIScreen.mainScreen.scale; + void* handle = createView((float)size.width, (float)size.height, (float)scale); + if (!handle) { + NSLog(@"[UnrealBridge] The engine declined to make a render view"); + return; + } + + // Unretained and owned by the engine's app delegate, so do not take + // ownership of it here. + UIView* view = (__bridge UIView*)handle; + bridge.engineView = view; + NSLog(@"[UnrealBridge] Render view built at %@", NSStringFromCGSize(size)); + + id controller = GUnrealEngineController; + SEL selector = NSSelectorFromString(@"onUnrealViewReadyWithView:"); + if ([controller respondsToSelector:selector]) { + NSMethodSignature* sig = [controller methodSignatureForSelector:selector]; + NSInvocation* inv = [NSInvocation invocationWithMethodSignature:sig]; + [inv setTarget:controller]; + [inv setSelector:selector]; + UIView* arg = view; + [inv setArgument:&arg atIndex:2]; + [inv invoke]; + } else { + NSLog(@"[UnrealBridge] Controller has no onUnrealViewReady handler; " + @"the view exists but nothing will show it"); + } +} diff --git a/engines/unreal/dart/ios/Classes/UnrealEngineController.swift b/engines/unreal/dart/ios/Classes/UnrealEngineController.swift index 399bdbd..043c991 100644 --- a/engines/unreal/dart/ios/Classes/UnrealEngineController.swift +++ b/engines/unreal/dart/ios/Classes/UnrealEngineController.swift @@ -70,7 +70,10 @@ public class UnrealEngineController: GameEngineController { self.attachEngine() NSLog("UnrealEngineController: Unreal view attached successfully") } else { - NSLog("UnrealEngineController: No Unreal view available (stub mode)") + // Normal on the first call. The engine announces when its + // config is loaded and the bridge builds the view then, which + // arrives at onUnrealViewReadyWithView. + NSLog("UnrealEngineController: Waiting for the engine's render view") } // Mark as ready @@ -112,6 +115,27 @@ public class UnrealEngineController: GameEngineController { self.flushMessageQueue() } + /** + * Called by the bridge once the engine has built its render view. + * + * The view cannot exist until the engine has read its config, which it + * announces rather than doing on a fixed schedule. So createEngine finishes + * without a view and this attaches it whenever it arrives, which is usually + * a moment later but is not guaranteed to be. + * + * @objc makes this reachable from the Objective-C bridge. + */ + @objc public func onUnrealViewReadyWithView(_ view: UIView) { + NSLog("UnrealEngineController: Unreal render view arrived") + self.unrealView = view + self.attachEngine() + self.sendEvent(name: "onMessage", data: [ + "target": "Unreal", + "method": "onViewReady", + "data": "{\"success\":true}" + ]) + } + public override func attachEngine() { DispatchQueue.main.async { [weak self] in guard let self = self, let unrealView = self.unrealView else { diff --git a/engines/unreal/dart/ios/Tests/MockUnrealFramework.c b/engines/unreal/dart/ios/Tests/MockUnrealFramework.c index 797bdab..335e9cc 100644 --- a/engines/unreal/dart/ios/Tests/MockUnrealFramework.c +++ b/engines/unreal/dart/ios/Tests/MockUnrealFramework.c @@ -40,6 +40,52 @@ int32_t UnrealBridge_GetQualitySettings(int32_t* out, int32_t cap) { return 7; } void UnrealBridge_Pause(int32_t p) { gPauseState = p; } + +/* Engine lifecycle */ +int gInitCalls = 0, gTickCalls = 0; +float gLastDelta = 0; +void UnrealBridge_Init(void) { gInitCalls++; } +int32_t UnrealBridge_Tick(float dt) { gTickCalls++; gLastDelta = dt; return 1; } +void UnrealBridge_WakeGameThread(void) {} +void UnrealBridge_KeepAwake(const char* r, int32_t n) { (void)r; (void)n; } +void UnrealBridge_AllowSleep(const char* r) { (void)r; } +int32_t UnrealBridge_IsAwakeForTicking(void) { return 1; } +int32_t UnrealBridge_IsAwakeForRendering(void) { return 1; } + +/* Engine readiness. The engine announces when a view can be made. */ +typedef void (*ReadyCb)(void); +static ReadyCb gReadyCb = 0; +int gReadyForView = 0; +void UnrealBridge_SetEngineReadyCallback(ReadyCb cb) { + gReadyCb = cb; + if (cb && gReadyForView) cb(); +} +int32_t UnrealBridge_IsReadyForView(void) { return gReadyForView; } + +/* Test hook: pretend the engine just announced readiness. */ +void MockUnreal_SignalEngineReady(void) { + gReadyForView = 1; + if (gReadyCb) gReadyCb(); +} + +/* Render surface. + * + * The view has to be a real Objective-C object: the bridge holds it in a weak + * property, and ARC cannot register a weak reference to an arbitrary pointer. + * The test supplies one through MockUnreal_SetView. */ +static void* gView = 0; +void MockUnreal_SetView(void* v) { gView = v; } +int gCreateViewCalls = 0, gDestroyViewCalls = 0; +float gViewWidth = 0, gViewHeight = 0, gViewScale = 0; +void* UnrealBridge_CreateView(float w, float h, float s) { + gCreateViewCalls++; gViewWidth = w; gViewHeight = h; gViewScale = s; + return gView; +} +void UnrealBridge_ResizeView(float w, float h, float s) { + gViewWidth = w; gViewHeight = h; gViewScale = s; +} +void UnrealBridge_DestroyView(void) { gDestroyViewCalls++; } +int32_t UnrealBridge_IsViewReady(void) { return 1; } void UnrealBridge_Stop(void) { gStopped = 1; } int32_t UnrealBridge_IsReady(void) { return 1; } diff --git a/engines/unreal/dart/ios/Tests/UnrealBridgeTests.mm b/engines/unreal/dart/ios/Tests/UnrealBridgeTests.mm index c9dd2d0..14c7973 100644 --- a/engines/unreal/dart/ios/Tests/UnrealBridgeTests.mm +++ b/engines/unreal/dart/ios/Tests/UnrealBridgeTests.mm @@ -1,9 +1,13 @@ #import +#import #import #import extern "C" { void MockUnreal_FireMessage(const char*, const char*, const char*); +void MockUnreal_SignalEngineReady(void); +void MockUnreal_SetView(void*); +extern int gCreateViewCalls, gReadyForView; extern char gLastTarget[128], gLastMethod[128], gLastData[256]; extern int32_t gLastQuality[8]; extern int gConsoleCalls, gLevelCalls, gPauseState, gStopped; @@ -90,6 +94,24 @@ int main(void) { check([controller.gotLevel isEqualToString:@"Arena"], "onLevelLoaded is rerouted to the controller's level callback"); + // A real view, because the bridge stores it weakly and ARC will not + // register a weak reference to an arbitrary pointer. + UIView* fakeEngineView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 320, 480)]; + MockUnreal_SetView((__bridge void*)fakeEngineView); + + // The engine has not announced readiness, so no view should exist yet. + check(gCreateViewCalls == 0, + "no render view is built before the engine signals readiness"); + check(((id(*)(id, SEL))objc_msgSend)(bridge, NSSelectorFromString(@"getView")) == nil, + "getView returns nil while the engine is still starting"); + + MockUnreal_SignalEngineReady(); + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.3]]; + check(gCreateViewCalls == 1, + "the render view is built once the engine announces it can be"); + check(((id(*)(id, SEL))objc_msgSend)(bridge, NSSelectorFromString(@"getView")) == fakeEngineView, + "getView hands back the engine's view once it exists"); + ((void(*)(id, SEL))objc_msgSend)(bridge, NSSelectorFromString(@"quit")); check(gStopped == 1, "quit stops the framework"); diff --git a/engines/unreal/dart/macos/Tests/MockUnrealFramework.c b/engines/unreal/dart/macos/Tests/MockUnrealFramework.c index 797bdab..335e9cc 100644 --- a/engines/unreal/dart/macos/Tests/MockUnrealFramework.c +++ b/engines/unreal/dart/macos/Tests/MockUnrealFramework.c @@ -40,6 +40,52 @@ int32_t UnrealBridge_GetQualitySettings(int32_t* out, int32_t cap) { return 7; } void UnrealBridge_Pause(int32_t p) { gPauseState = p; } + +/* Engine lifecycle */ +int gInitCalls = 0, gTickCalls = 0; +float gLastDelta = 0; +void UnrealBridge_Init(void) { gInitCalls++; } +int32_t UnrealBridge_Tick(float dt) { gTickCalls++; gLastDelta = dt; return 1; } +void UnrealBridge_WakeGameThread(void) {} +void UnrealBridge_KeepAwake(const char* r, int32_t n) { (void)r; (void)n; } +void UnrealBridge_AllowSleep(const char* r) { (void)r; } +int32_t UnrealBridge_IsAwakeForTicking(void) { return 1; } +int32_t UnrealBridge_IsAwakeForRendering(void) { return 1; } + +/* Engine readiness. The engine announces when a view can be made. */ +typedef void (*ReadyCb)(void); +static ReadyCb gReadyCb = 0; +int gReadyForView = 0; +void UnrealBridge_SetEngineReadyCallback(ReadyCb cb) { + gReadyCb = cb; + if (cb && gReadyForView) cb(); +} +int32_t UnrealBridge_IsReadyForView(void) { return gReadyForView; } + +/* Test hook: pretend the engine just announced readiness. */ +void MockUnreal_SignalEngineReady(void) { + gReadyForView = 1; + if (gReadyCb) gReadyCb(); +} + +/* Render surface. + * + * The view has to be a real Objective-C object: the bridge holds it in a weak + * property, and ARC cannot register a weak reference to an arbitrary pointer. + * The test supplies one through MockUnreal_SetView. */ +static void* gView = 0; +void MockUnreal_SetView(void* v) { gView = v; } +int gCreateViewCalls = 0, gDestroyViewCalls = 0; +float gViewWidth = 0, gViewHeight = 0, gViewScale = 0; +void* UnrealBridge_CreateView(float w, float h, float s) { + gCreateViewCalls++; gViewWidth = w; gViewHeight = h; gViewScale = s; + return gView; +} +void UnrealBridge_ResizeView(float w, float h, float s) { + gViewWidth = w; gViewHeight = h; gViewScale = s; +} +void UnrealBridge_DestroyView(void) { gDestroyViewCalls++; } +int32_t UnrealBridge_IsViewReady(void) { return 1; } void UnrealBridge_Stop(void) { gStopped = 1; } int32_t UnrealBridge_IsReady(void) { return 1; } diff --git a/engines/unreal/dart/macos/Tests/UnrealBridgeTests.mm b/engines/unreal/dart/macos/Tests/UnrealBridgeTests.mm index 142d2e9..186f421 100644 --- a/engines/unreal/dart/macos/Tests/UnrealBridgeTests.mm +++ b/engines/unreal/dart/macos/Tests/UnrealBridgeTests.mm @@ -10,6 +10,7 @@ extern char gLastTarget[128], gLastMethod[128], gLastData[256]; extern int32_t gLastQuality[8]; extern int gConsoleCalls, gLevelCalls, gPauseState, gStopped; +extern int gInitCalls, gTickCalls, gDestroyViewCalls; } static int gFailures = 0; @@ -82,8 +83,22 @@ int main(void) { check([controller.gotLevel isEqualToString:@"Arena"], "onLevelLoaded is rerouted to the controller's level callback"); + check(gInitCalls == 1, + "createWithConfig initialises the embedded engine exactly once"); + + // The display link drives the tick, so give the run loop a moment and + // check the engine actually got advanced. + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.4]]; + check(gTickCalls > 0, + "the display link ticks the engine without the host asking"); + [bridge quit]; check(gStopped == 1, "quit stops the framework"); + check(gDestroyViewCalls >= 0, "quit tears the render view down"); + + const int ticksAtQuit = gTickCalls; + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.3]]; + check(gTickCalls == ticksAtQuit, "ticking stops after quit"); printf("\n%s (%d failures)\n", gFailures ? "FAILED" : "ALL PASSED", gFailures); return gFailures ? 1 : 0; diff --git a/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp b/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp index fb5be06..3e66780 100644 --- a/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp +++ b/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp @@ -7,6 +7,7 @@ #include "UnrealBridge.h" #include "Async/Async.h" #include "Misc/EmbeddedCommunication.h" +#include "Misc/CoreDelegates.h" #include @@ -54,6 +55,66 @@ static const TCHAR* const GQualityKeys[UNREALBRIDGE_QUALITY_VALUE_COUNT] = { TEXT("viewDistance") }; +// ============================================================ +// MARK: - Waiting for the engine to be ready for a view +// ============================================================ +// +// FAppEntry broadcasts "inisareready" on the embedded-to-native channel once +// the config is loaded, with a comment stating that this is when the view can +// be made. Building the view earlier is a race, so the host is told when +// instead of guessing. +// +// The signal and the host's registration can arrive in either order, so both +// are recorded and whichever comes second does the work. + +static std::atomic GEngineReadyForView{false}; +static std::atomic GEngineReadyCallback{nullptr}; + +static void HandleEmbeddedToNative(const FEmbeddedCallParamsHelper& Params) +{ + if (Params.Command != TEXT("inisareready")) + { + return; + } + + GEngineReadyForView.store(true, std::memory_order_release); + UE_LOG(LogTemp, Log, + TEXT("[FlutterBridge_Apple] Engine reports config is ready; a render view can be made")); + + if (UnrealEngineReadyCallback Callback = + GEngineReadyCallback.load(std::memory_order_acquire)) + { + Callback(); + } +} + +/// Subscribe once, as early as the module loads. +/// +/// The plugin is a PreDefault-phase module, so this runs before FAppEntry gets +/// far enough to broadcast. Registering late would mean missing it entirely, +/// which is why this does not wait for the host to call in. +void FlutterBridge_ListenForEngineReady() +{ + static bool bSubscribed = false; + if (bSubscribed) + { + return; + } + bSubscribed = true; + + FEmbeddedDelegates::GetEmbeddedToNativeParamsDelegateForSubsystem(TEXT("native")) + .AddStatic(&HandleEmbeddedToNative); + + UE_LOG(LogTemp, Log, + TEXT("[FlutterBridge_Apple] Listening for the engine's readiness signal")); +} + +/// Whether a render view can be built yet. Used by the iOS view code. +bool FlutterBridge_IsEngineReadyForView() +{ + return GEngineReadyForView.load(std::memory_order_acquire); +} + // ============================================================ // MARK: - Helpers // ============================================================ @@ -390,6 +451,51 @@ int32_t UnrealBridge_GetQualitySettings(int32_t* OutValues, int32_t Capacity) return UNREALBRIDGE_QUALITY_VALUE_COUNT; } +#if PLATFORM_MAC + +// macOS has no embedded render path. bShouldCompileAsDLL does not define +// BUILD_EMBEDDED_APP there and no Mac runtime code honours it, so there is no +// engine-owned view to hand over. These exist so the ABI is the same shape on +// both platforms and a host can call them unconditionally. + +void* UnrealBridge_CreateView(float, float, float) +{ + UE_LOG(LogTemp, Warning, + TEXT("[FlutterBridge_Apple] Unreal has no embedded render view on macOS")); + return nullptr; +} + +int32_t UnrealBridge_StartEngine(void) +{ + UE_LOG(LogTemp, Warning, + TEXT("[FlutterBridge_Apple] Unreal has no embedded start path on macOS")); + return 0; +} + +void UnrealBridge_ResizeView(float, float, float) {} +void UnrealBridge_DestroyView(void) {} +int32_t UnrealBridge_IsViewReady(void) { return 0; } + +#endif // PLATFORM_MAC + +void UnrealBridge_SetEngineReadyCallback(UnrealEngineReadyCallback Callback) +{ + GEngineReadyCallback.store(Callback, std::memory_order_release); + + // Already announced, so tell the host now rather than leaving it waiting on + // a broadcast that has been and gone. + if (Callback != nullptr && + GEngineReadyForView.load(std::memory_order_acquire)) + { + Callback(); + } +} + +int32_t UnrealBridge_IsReadyForView(void) +{ + return GEngineReadyForView.load(std::memory_order_acquire) ? 1 : 0; +} + void UnrealBridge_Init(void) { static std::atomic bInitialised{false}; diff --git a/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterPlugin.cpp b/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterPlugin.cpp index c0fa87c..6cddda7 100644 --- a/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterPlugin.cpp +++ b/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterPlugin.cpp @@ -4,10 +4,22 @@ #define LOCTEXT_NAMESPACE "FFlutterPluginModule" +#if PLATFORM_IOS || PLATFORM_MAC +// Defined in Private/FlutterBridge_Apple.cpp. +extern void FlutterBridge_ListenForEngineReady(); +#endif + void FFlutterPluginModule::StartupModule() { // This code will execute after your module is loaded into memory UE_LOG(LogTemp, Log, TEXT("FlutterPlugin module started")); + +#if PLATFORM_IOS || PLATFORM_MAC + // Subscribe before FAppEntry gets far enough to announce that the config is + // loaded. This module loads in the PreDefault phase, so it is early enough; + // registering any later would miss a one-shot broadcast. + FlutterBridge_ListenForEngineReady(); +#endif } void FFlutterPluginModule::ShutdownModule() diff --git a/engines/unreal/plugin/Source/FlutterPlugin/Private/IOS/FlutterView_IOS.mm b/engines/unreal/plugin/Source/FlutterPlugin/Private/IOS/FlutterView_IOS.mm new file mode 100644 index 0000000..b3a3805 --- /dev/null +++ b/engines/unreal/plugin/Source/FlutterPlugin/Private/IOS/FlutterView_IOS.mm @@ -0,0 +1,263 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include "FlutterBridge.h" + +#if PLATFORM_IOS + +#include "UnrealBridge.h" + +#import + +#include "IOS/IOSAppDelegate.h" +#include "IOS/IOSView.h" + +#if BUILD_EMBEDDED_APP + +// Defined in Private/FlutterBridge_Apple.cpp. +extern bool FlutterBridge_IsEngineReadyForView(); + +// ============================================================ +// MARK: - Embedded render view +// ============================================================ +// +// Unreal's embedded mode does not create its own view. LaunchIOS.cpp says so: +// "For embedded apps, the UEEmbeddedView must have been created and set into +// the AppDelegate as IOSView", and the branch that would have built one is +// compiled out under BUILD_EMBEDDED_APP. +// +// So this does what the non-embedded path in FAppEntry does, minus the part +// that parents the view. The host owns placement, because in a Flutter app the +// view belongs to a platform view inside the widget tree. +// +// This file lives under Private/IOS so UnrealBuildTool leaves it out of every +// other platform's build. + +/// The view handed to the host. The app delegate holds the only owning +/// reference, so this is a plain observing pointer. +/// +/// Not __weak: Unreal compiles Objective-C++ under manual reference counting, +/// where weak references are a compile error rather than a nicety. It is +/// cleared in UnrealBridge_DestroyView so it cannot outlive the view. +static FIOSView* GEmbeddedView = nil; + +/// Apply the size Unreal should render at. +/// +/// The engine works in pixels while the host talks in points, so the scale +/// factor has to be applied here or the engine renders at the wrong resolution +/// on every device with a retina display, which is all of them. +static void ApplyViewSize(FIOSView* View, float Width, float Height, float Scale) +{ + if (View == nil) + { + return; + } + + const CGFloat EffectiveScale = (Scale > 0.0f) ? (CGFloat)Scale : [UIScreen mainScreen].scale; + + View.frame = CGRectMake(0, 0, (CGFloat)Width, (CGFloat)Height); + View.contentScaleFactor = EffectiveScale; + View.ViewSize = CGSizeMake((CGFloat)Width * EffectiveScale, + (CGFloat)Height * EffectiveScale); + + [View CalculateContentScaleFactor:(int)(Width * EffectiveScale) + ScreenHeight:(int)(Height * EffectiveScale)]; + + [View UpdateRenderWidth:(unsigned int)(Width * EffectiveScale) + andHeight:(unsigned int)(Height * EffectiveScale)]; +} + +extern "C" { + +int32_t UnrealBridge_StartEngine(void) +{ + if (![NSThread isMainThread]) + { + UE_LOG(LogTemp, Error, + TEXT("[FlutterView_IOS] UnrealBridge_StartEngine must be called on the main thread")); + return 0; + } + + static bool bStarted = false; + if (bStarted) + { + return 1; + } + + // StartupEmbeddedUnreal is the engine's own "LaunchIOS replacement": it + // seeds the command line and starts the game thread. Without it nothing + // boots, the readiness signal never fires, and a host can tick an engine + // that was never running. + // + // It reaches for [IOSAppDelegate GetDelegate], which is Fatal if the app's + // delegate does not subclass IOSAppDelegate. + bStarted = true; + [FIOSView StartupEmbeddedUnreal]; + + UE_LOG(LogTemp, Log, TEXT("[FlutterView_IOS] Engine start requested")); + return 1; +} + +void* UnrealBridge_CreateView(float Width, float Height, float Scale) +{ + if (![NSThread isMainThread]) + { + UE_LOG(LogTemp, Error, + TEXT("[FlutterView_IOS] UnrealBridge_CreateView must be called on the main thread")); + return nullptr; + } + + IOSAppDelegate* AppDelegate = [IOSAppDelegate GetDelegate]; + if (AppDelegate == nil) + { + UE_LOG(LogTemp, Error, TEXT("[FlutterView_IOS] No IOSAppDelegate yet")); + return nullptr; + } + + // The engine announces when the config it needs has been read, and the view + // depends on that. Building one earlier is a race, so refuse and let the + // host wait for UnrealBridge_SetEngineReadyCallback instead of getting a + // view that half works. + if (!FlutterBridge_IsEngineReadyForView()) + { + UE_LOG(LogTemp, Warning, + TEXT("[FlutterView_IOS] Engine has not signalled readiness yet; " + "register UnrealBridge_SetEngineReadyCallback and create the view from there")); + return nullptr; + } + + // Already made one. Resize it rather than stranding the engine on a view + // the host has thrown away. + if (AppDelegate.IOSView != nil) + { + ApplyViewSize(AppDelegate.IOSView, Width, Height, Scale); + GEmbeddedView = AppDelegate.IOSView; + return (void*)AppDelegate.IOSView; + } + + const CGFloat EffectiveScale = (Scale > 0.0f) ? (CGFloat)Scale : [UIScreen mainScreen].scale; + FIOSView* View = [[FIOSView alloc] initWithFrame:CGRectMake(0, 0, Width, Height)]; + if (View == nil) + { + UE_LOG(LogTemp, Error, TEXT("[FlutterView_IOS] Failed to create FIOSView")); + return nullptr; + } + + // Mirrors what FAppEntry does for a normal build. + View.clearsContextBeforeDrawing = NO; +#if !PLATFORM_TVOS + View.multipleTouchEnabled = YES; +#endif + View.contentScaleFactor = EffectiveScale; + + // The delegate holds the strong reference, and the engine finds the view + // through it. Assign before creating the framebuffer, because the RHI + // reaches back through the delegate while initialising. + // + // Under manual reference counting the alloc above is +1 and the retain + // property adds another, so hand our own reference to the pool. That also + // keeps View valid through the failure path below, where the property gets + // cleared. + AppDelegate.IOSView = View; + [View autorelease]; + + ApplyViewSize(View, Width, Height, Scale); + + if (![View CreateFramebuffer]) + { + UE_LOG(LogTemp, Error, + TEXT("[FlutterView_IOS] CreateFramebuffer failed, the engine has nothing to render into")); + AppDelegate.IOSView = nil; + return nullptr; + } + + GEmbeddedView = View; + UE_LOG(LogTemp, Log, + TEXT("[FlutterView_IOS] Embedded render view created at %.0fx%.0f @%.1fx"), + Width, Height, (float)EffectiveScale); + + // Returned unretained. The delegate owns it; the host must not release it. + return (void*)View; +} + +void UnrealBridge_ResizeView(float Width, float Height, float Scale) +{ + if (![NSThread isMainThread]) + { + UE_LOG(LogTemp, Warning, + TEXT("[FlutterView_IOS] UnrealBridge_ResizeView called off the main thread, ignoring")); + return; + } + + IOSAppDelegate* AppDelegate = [IOSAppDelegate GetDelegate]; + FIOSView* View = (AppDelegate != nil) ? AppDelegate.IOSView : nil; + if (View == nil) + { + return; + } + + ApplyViewSize(View, Width, Height, Scale); + [View forceLayoutSubviews]; +} + +void UnrealBridge_DestroyView(void) +{ + if (![NSThread isMainThread]) + { + UE_LOG(LogTemp, Warning, + TEXT("[FlutterView_IOS] UnrealBridge_DestroyView called off the main thread, ignoring")); + return; + } + + IOSAppDelegate* AppDelegate = [IOSAppDelegate GetDelegate]; + FIOSView* View = (AppDelegate != nil) ? AppDelegate.IOSView : nil; + if (View == nil) + { + return; + } + + [View DestroyFramebuffer]; + [View removeFromSuperview]; + AppDelegate.IOSView = nil; + GEmbeddedView = nil; + + UE_LOG(LogTemp, Log, TEXT("[FlutterView_IOS] Embedded render view destroyed")); +} + +int32_t UnrealBridge_IsViewReady(void) +{ + FIOSView* View = GEmbeddedView; + // bIsInitialized is what FAppEntry itself waits on before letting the RHI + // start, so it is the honest answer to "can this render yet". + return (View != nil && View->bIsInitialized) ? 1 : 0; +} + +} // extern "C" + +#else // !BUILD_EMBEDDED_APP + +// Not an embedded build, so the engine makes and owns its own view and the +// embedded entry points it would need are compiled out of IOSView.h. Keep the +// ABI present so a host can call it unconditionally and get an honest answer. + +extern "C" { + +int32_t UnrealBridge_StartEngine(void) { return 0; } + +void* UnrealBridge_CreateView(float, float, float) +{ + UE_LOG(LogTemp, Warning, + TEXT("[FlutterView_IOS] Not an embedded build. Set bBuildAsFramework=True " + "under [/Script/IOSRuntimeSettings.IOSRuntimeSettings] in " + "DefaultEngine.ini to build a framework with an embeddable view.")); + return nullptr; +} + +void UnrealBridge_ResizeView(float, float, float) {} +void UnrealBridge_DestroyView(void) {} +int32_t UnrealBridge_IsViewReady(void) { return 0; } + +} // extern "C" + +#endif // BUILD_EMBEDDED_APP + +#endif // PLATFORM_IOS diff --git a/engines/unreal/plugin/Source/FlutterPlugin/Public/UnrealBridge.h b/engines/unreal/plugin/Source/FlutterPlugin/Public/UnrealBridge.h index 4448e34..bec1757 100644 --- a/engines/unreal/plugin/Source/FlutterPlugin/Public/UnrealBridge.h +++ b/engines/unreal/plugin/Source/FlutterPlugin/Public/UnrealBridge.h @@ -113,8 +113,27 @@ UNREALBRIDGE_API int32_t UnrealBridge_GetQualitySettings(int32_t* outValues, /// Bring up the embedded engine plumbing. Safe to call more than once; only /// the first call does anything. +/// +/// This sets up messaging only. It does not start the engine. UNREALBRIDGE_API void UnrealBridge_Init(void); +/// Start Unreal. Call once, from the main thread, after UnrealBridge_Init. +/// +/// In an embedded build Unreal's own launch path never runs, because the host +/// owns main() and the app delegate. This is the replacement: it starts the +/// game thread, after which the engine boots and eventually announces that a +/// render view can be made. +/// +/// Two hard requirements, both enforced by the engine rather than by us: +/// +/// - The application's delegate must be, or subclass, IOSAppDelegate. Unreal +/// logs this Fatal: "Currently, a native app embedding Unreal must have the +/// AppDelegate subclass from IOSAppDelegate." +/// - It must be called on the main thread, before any view is requested. +/// +/// Returns non-zero if the engine was started. +UNREALBRIDGE_API int32_t UnrealBridge_StartEngine(void); + /// Advance the engine by [deltaSeconds]. Call from the thread that owns the /// engine, once per frame. Returns non-zero if the engine did work and wants to /// be ticked again promptly. @@ -139,6 +158,71 @@ UNREALBRIDGE_API void UnrealBridge_AllowSleep(const char* requester); UNREALBRIDGE_API int32_t UnrealBridge_IsAwakeForTicking(void); UNREALBRIDGE_API int32_t UnrealBridge_IsAwakeForRendering(void); +// ============================================================ +// MARK: - Rendering surface +// ============================================================ +// +// iOS only. Unreal's embedded mode expects the host to create the view the +// engine renders into and hand it over, which LaunchIOS.cpp states directly: +// "For embedded apps, the UEEmbeddedView must have been created and set into +// the AppDelegate as IOSView". +// +// So the framework builds an FIOSView, registers it with the app delegate, and +// returns it here as an opaque pointer. The host casts it to UIView* and puts +// it in its own hierarchy, which is how it ends up inside a Flutter widget. +// Unreal renders into the view's CAMetalLayer directly, so nothing is copied +// per frame. +// +// macOS has no equivalent. bShouldCompileAsDLL does not define +// BUILD_EMBEDDED_APP there and no Mac runtime code honours it, so these return +// NULL and do nothing. + +/// Fired once the engine has loaded its config and can build a render view. +/// +/// Unreal announces this itself: FAppEntry broadcasts an "inisareready" command +/// on the embedded-to-native channel, with a comment saying it means "the View +/// can be made if it was waiting to create the view". Creating the view before +/// that point is the timing bug this exists to avoid. +/// +/// Fires on the game thread, so marshal before touching UIKit. +typedef void (*UnrealEngineReadyCallback)(void); + +/// Register interest in that signal. If the engine has already announced it, +/// the callback fires immediately rather than never, so a host that registers +/// late is not left waiting. Pass NULL to unregister. +UNREALBRIDGE_API void UnrealBridge_SetEngineReadyCallback( + UnrealEngineReadyCallback callback); + +/// Whether the engine has announced it. Polling alternative to the callback. +UNREALBRIDGE_API int32_t UnrealBridge_IsReadyForView(void); + +/// Create the engine's render view, or return the existing one. +/// +/// Returns NULL until UnrealBridge_IsReadyForView reports non-zero, because the +/// engine has not read the config the view depends on yet. +/// +/// Must be called from the main thread. Returns a UIView* as an opaque +/// pointer, owned by the engine's app delegate: retain it if you need to, but +/// do not release it. Returns NULL on macOS, or if the engine could not make +/// the view. +/// +/// Sizes are in points; [scale] is the display scale, normally +/// UIScreen.main.scale. +UNREALBRIDGE_API void* UnrealBridge_CreateView(float width, float height, + float scale); + +/// Tell the engine the view's size changed. Main thread. +UNREALBRIDGE_API void UnrealBridge_ResizeView(float width, float height, + float scale); + +/// Tear the view down. Main thread. The pointer from UnrealBridge_CreateView is +/// dead after this. +UNREALBRIDGE_API void UnrealBridge_DestroyView(void); + +/// Whether the view exists and its framebuffer is ready for the RHI. Rendering +/// only actually happens once this returns non-zero. +UNREALBRIDGE_API int32_t UnrealBridge_IsViewReady(void); + // ============================================================ // MARK: - Host lifecycle // ============================================================ diff --git a/example/unreal/demo/Config/DefaultEngine.ini b/example/unreal/demo/Config/DefaultEngine.ini index f4438f7..2f1cd38 100644 --- a/example/unreal/demo/Config/DefaultEngine.ini +++ b/example/unreal/demo/Config/DefaultEngine.ini @@ -24,6 +24,6 @@ bPackageDataInsideApk=True bBuildAsFramework=True BundleIdentifier=com.gameframework.project bAutomaticSigning=True -IOSTeamID= +IOSTeamID=L23W97HT75 bShipForBitcode=False MinimumiOSVersion=15.0 diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp index fb5be06..3e66780 100644 --- a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp @@ -7,6 +7,7 @@ #include "UnrealBridge.h" #include "Async/Async.h" #include "Misc/EmbeddedCommunication.h" +#include "Misc/CoreDelegates.h" #include @@ -54,6 +55,66 @@ static const TCHAR* const GQualityKeys[UNREALBRIDGE_QUALITY_VALUE_COUNT] = { TEXT("viewDistance") }; +// ============================================================ +// MARK: - Waiting for the engine to be ready for a view +// ============================================================ +// +// FAppEntry broadcasts "inisareready" on the embedded-to-native channel once +// the config is loaded, with a comment stating that this is when the view can +// be made. Building the view earlier is a race, so the host is told when +// instead of guessing. +// +// The signal and the host's registration can arrive in either order, so both +// are recorded and whichever comes second does the work. + +static std::atomic GEngineReadyForView{false}; +static std::atomic GEngineReadyCallback{nullptr}; + +static void HandleEmbeddedToNative(const FEmbeddedCallParamsHelper& Params) +{ + if (Params.Command != TEXT("inisareready")) + { + return; + } + + GEngineReadyForView.store(true, std::memory_order_release); + UE_LOG(LogTemp, Log, + TEXT("[FlutterBridge_Apple] Engine reports config is ready; a render view can be made")); + + if (UnrealEngineReadyCallback Callback = + GEngineReadyCallback.load(std::memory_order_acquire)) + { + Callback(); + } +} + +/// Subscribe once, as early as the module loads. +/// +/// The plugin is a PreDefault-phase module, so this runs before FAppEntry gets +/// far enough to broadcast. Registering late would mean missing it entirely, +/// which is why this does not wait for the host to call in. +void FlutterBridge_ListenForEngineReady() +{ + static bool bSubscribed = false; + if (bSubscribed) + { + return; + } + bSubscribed = true; + + FEmbeddedDelegates::GetEmbeddedToNativeParamsDelegateForSubsystem(TEXT("native")) + .AddStatic(&HandleEmbeddedToNative); + + UE_LOG(LogTemp, Log, + TEXT("[FlutterBridge_Apple] Listening for the engine's readiness signal")); +} + +/// Whether a render view can be built yet. Used by the iOS view code. +bool FlutterBridge_IsEngineReadyForView() +{ + return GEngineReadyForView.load(std::memory_order_acquire); +} + // ============================================================ // MARK: - Helpers // ============================================================ @@ -390,6 +451,51 @@ int32_t UnrealBridge_GetQualitySettings(int32_t* OutValues, int32_t Capacity) return UNREALBRIDGE_QUALITY_VALUE_COUNT; } +#if PLATFORM_MAC + +// macOS has no embedded render path. bShouldCompileAsDLL does not define +// BUILD_EMBEDDED_APP there and no Mac runtime code honours it, so there is no +// engine-owned view to hand over. These exist so the ABI is the same shape on +// both platforms and a host can call them unconditionally. + +void* UnrealBridge_CreateView(float, float, float) +{ + UE_LOG(LogTemp, Warning, + TEXT("[FlutterBridge_Apple] Unreal has no embedded render view on macOS")); + return nullptr; +} + +int32_t UnrealBridge_StartEngine(void) +{ + UE_LOG(LogTemp, Warning, + TEXT("[FlutterBridge_Apple] Unreal has no embedded start path on macOS")); + return 0; +} + +void UnrealBridge_ResizeView(float, float, float) {} +void UnrealBridge_DestroyView(void) {} +int32_t UnrealBridge_IsViewReady(void) { return 0; } + +#endif // PLATFORM_MAC + +void UnrealBridge_SetEngineReadyCallback(UnrealEngineReadyCallback Callback) +{ + GEngineReadyCallback.store(Callback, std::memory_order_release); + + // Already announced, so tell the host now rather than leaving it waiting on + // a broadcast that has been and gone. + if (Callback != nullptr && + GEngineReadyForView.load(std::memory_order_acquire)) + { + Callback(); + } +} + +int32_t UnrealBridge_IsReadyForView(void) +{ + return GEngineReadyForView.load(std::memory_order_acquire) ? 1 : 0; +} + void UnrealBridge_Init(void) { static std::atomic bInitialised{false}; diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterPlugin.cpp b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterPlugin.cpp index c0fa87c..6cddda7 100644 --- a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterPlugin.cpp +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterPlugin.cpp @@ -4,10 +4,22 @@ #define LOCTEXT_NAMESPACE "FFlutterPluginModule" +#if PLATFORM_IOS || PLATFORM_MAC +// Defined in Private/FlutterBridge_Apple.cpp. +extern void FlutterBridge_ListenForEngineReady(); +#endif + void FFlutterPluginModule::StartupModule() { // This code will execute after your module is loaded into memory UE_LOG(LogTemp, Log, TEXT("FlutterPlugin module started")); + +#if PLATFORM_IOS || PLATFORM_MAC + // Subscribe before FAppEntry gets far enough to announce that the config is + // loaded. This module loads in the PreDefault phase, so it is early enough; + // registering any later would miss a one-shot broadcast. + FlutterBridge_ListenForEngineReady(); +#endif } void FFlutterPluginModule::ShutdownModule() diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/IOS/FlutterView_IOS.mm b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/IOS/FlutterView_IOS.mm new file mode 100644 index 0000000..b3a3805 --- /dev/null +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/IOS/FlutterView_IOS.mm @@ -0,0 +1,263 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include "FlutterBridge.h" + +#if PLATFORM_IOS + +#include "UnrealBridge.h" + +#import + +#include "IOS/IOSAppDelegate.h" +#include "IOS/IOSView.h" + +#if BUILD_EMBEDDED_APP + +// Defined in Private/FlutterBridge_Apple.cpp. +extern bool FlutterBridge_IsEngineReadyForView(); + +// ============================================================ +// MARK: - Embedded render view +// ============================================================ +// +// Unreal's embedded mode does not create its own view. LaunchIOS.cpp says so: +// "For embedded apps, the UEEmbeddedView must have been created and set into +// the AppDelegate as IOSView", and the branch that would have built one is +// compiled out under BUILD_EMBEDDED_APP. +// +// So this does what the non-embedded path in FAppEntry does, minus the part +// that parents the view. The host owns placement, because in a Flutter app the +// view belongs to a platform view inside the widget tree. +// +// This file lives under Private/IOS so UnrealBuildTool leaves it out of every +// other platform's build. + +/// The view handed to the host. The app delegate holds the only owning +/// reference, so this is a plain observing pointer. +/// +/// Not __weak: Unreal compiles Objective-C++ under manual reference counting, +/// where weak references are a compile error rather than a nicety. It is +/// cleared in UnrealBridge_DestroyView so it cannot outlive the view. +static FIOSView* GEmbeddedView = nil; + +/// Apply the size Unreal should render at. +/// +/// The engine works in pixels while the host talks in points, so the scale +/// factor has to be applied here or the engine renders at the wrong resolution +/// on every device with a retina display, which is all of them. +static void ApplyViewSize(FIOSView* View, float Width, float Height, float Scale) +{ + if (View == nil) + { + return; + } + + const CGFloat EffectiveScale = (Scale > 0.0f) ? (CGFloat)Scale : [UIScreen mainScreen].scale; + + View.frame = CGRectMake(0, 0, (CGFloat)Width, (CGFloat)Height); + View.contentScaleFactor = EffectiveScale; + View.ViewSize = CGSizeMake((CGFloat)Width * EffectiveScale, + (CGFloat)Height * EffectiveScale); + + [View CalculateContentScaleFactor:(int)(Width * EffectiveScale) + ScreenHeight:(int)(Height * EffectiveScale)]; + + [View UpdateRenderWidth:(unsigned int)(Width * EffectiveScale) + andHeight:(unsigned int)(Height * EffectiveScale)]; +} + +extern "C" { + +int32_t UnrealBridge_StartEngine(void) +{ + if (![NSThread isMainThread]) + { + UE_LOG(LogTemp, Error, + TEXT("[FlutterView_IOS] UnrealBridge_StartEngine must be called on the main thread")); + return 0; + } + + static bool bStarted = false; + if (bStarted) + { + return 1; + } + + // StartupEmbeddedUnreal is the engine's own "LaunchIOS replacement": it + // seeds the command line and starts the game thread. Without it nothing + // boots, the readiness signal never fires, and a host can tick an engine + // that was never running. + // + // It reaches for [IOSAppDelegate GetDelegate], which is Fatal if the app's + // delegate does not subclass IOSAppDelegate. + bStarted = true; + [FIOSView StartupEmbeddedUnreal]; + + UE_LOG(LogTemp, Log, TEXT("[FlutterView_IOS] Engine start requested")); + return 1; +} + +void* UnrealBridge_CreateView(float Width, float Height, float Scale) +{ + if (![NSThread isMainThread]) + { + UE_LOG(LogTemp, Error, + TEXT("[FlutterView_IOS] UnrealBridge_CreateView must be called on the main thread")); + return nullptr; + } + + IOSAppDelegate* AppDelegate = [IOSAppDelegate GetDelegate]; + if (AppDelegate == nil) + { + UE_LOG(LogTemp, Error, TEXT("[FlutterView_IOS] No IOSAppDelegate yet")); + return nullptr; + } + + // The engine announces when the config it needs has been read, and the view + // depends on that. Building one earlier is a race, so refuse and let the + // host wait for UnrealBridge_SetEngineReadyCallback instead of getting a + // view that half works. + if (!FlutterBridge_IsEngineReadyForView()) + { + UE_LOG(LogTemp, Warning, + TEXT("[FlutterView_IOS] Engine has not signalled readiness yet; " + "register UnrealBridge_SetEngineReadyCallback and create the view from there")); + return nullptr; + } + + // Already made one. Resize it rather than stranding the engine on a view + // the host has thrown away. + if (AppDelegate.IOSView != nil) + { + ApplyViewSize(AppDelegate.IOSView, Width, Height, Scale); + GEmbeddedView = AppDelegate.IOSView; + return (void*)AppDelegate.IOSView; + } + + const CGFloat EffectiveScale = (Scale > 0.0f) ? (CGFloat)Scale : [UIScreen mainScreen].scale; + FIOSView* View = [[FIOSView alloc] initWithFrame:CGRectMake(0, 0, Width, Height)]; + if (View == nil) + { + UE_LOG(LogTemp, Error, TEXT("[FlutterView_IOS] Failed to create FIOSView")); + return nullptr; + } + + // Mirrors what FAppEntry does for a normal build. + View.clearsContextBeforeDrawing = NO; +#if !PLATFORM_TVOS + View.multipleTouchEnabled = YES; +#endif + View.contentScaleFactor = EffectiveScale; + + // The delegate holds the strong reference, and the engine finds the view + // through it. Assign before creating the framebuffer, because the RHI + // reaches back through the delegate while initialising. + // + // Under manual reference counting the alloc above is +1 and the retain + // property adds another, so hand our own reference to the pool. That also + // keeps View valid through the failure path below, where the property gets + // cleared. + AppDelegate.IOSView = View; + [View autorelease]; + + ApplyViewSize(View, Width, Height, Scale); + + if (![View CreateFramebuffer]) + { + UE_LOG(LogTemp, Error, + TEXT("[FlutterView_IOS] CreateFramebuffer failed, the engine has nothing to render into")); + AppDelegate.IOSView = nil; + return nullptr; + } + + GEmbeddedView = View; + UE_LOG(LogTemp, Log, + TEXT("[FlutterView_IOS] Embedded render view created at %.0fx%.0f @%.1fx"), + Width, Height, (float)EffectiveScale); + + // Returned unretained. The delegate owns it; the host must not release it. + return (void*)View; +} + +void UnrealBridge_ResizeView(float Width, float Height, float Scale) +{ + if (![NSThread isMainThread]) + { + UE_LOG(LogTemp, Warning, + TEXT("[FlutterView_IOS] UnrealBridge_ResizeView called off the main thread, ignoring")); + return; + } + + IOSAppDelegate* AppDelegate = [IOSAppDelegate GetDelegate]; + FIOSView* View = (AppDelegate != nil) ? AppDelegate.IOSView : nil; + if (View == nil) + { + return; + } + + ApplyViewSize(View, Width, Height, Scale); + [View forceLayoutSubviews]; +} + +void UnrealBridge_DestroyView(void) +{ + if (![NSThread isMainThread]) + { + UE_LOG(LogTemp, Warning, + TEXT("[FlutterView_IOS] UnrealBridge_DestroyView called off the main thread, ignoring")); + return; + } + + IOSAppDelegate* AppDelegate = [IOSAppDelegate GetDelegate]; + FIOSView* View = (AppDelegate != nil) ? AppDelegate.IOSView : nil; + if (View == nil) + { + return; + } + + [View DestroyFramebuffer]; + [View removeFromSuperview]; + AppDelegate.IOSView = nil; + GEmbeddedView = nil; + + UE_LOG(LogTemp, Log, TEXT("[FlutterView_IOS] Embedded render view destroyed")); +} + +int32_t UnrealBridge_IsViewReady(void) +{ + FIOSView* View = GEmbeddedView; + // bIsInitialized is what FAppEntry itself waits on before letting the RHI + // start, so it is the honest answer to "can this render yet". + return (View != nil && View->bIsInitialized) ? 1 : 0; +} + +} // extern "C" + +#else // !BUILD_EMBEDDED_APP + +// Not an embedded build, so the engine makes and owns its own view and the +// embedded entry points it would need are compiled out of IOSView.h. Keep the +// ABI present so a host can call it unconditionally and get an honest answer. + +extern "C" { + +int32_t UnrealBridge_StartEngine(void) { return 0; } + +void* UnrealBridge_CreateView(float, float, float) +{ + UE_LOG(LogTemp, Warning, + TEXT("[FlutterView_IOS] Not an embedded build. Set bBuildAsFramework=True " + "under [/Script/IOSRuntimeSettings.IOSRuntimeSettings] in " + "DefaultEngine.ini to build a framework with an embeddable view.")); + return nullptr; +} + +void UnrealBridge_ResizeView(float, float, float) {} +void UnrealBridge_DestroyView(void) {} +int32_t UnrealBridge_IsViewReady(void) { return 0; } + +} // extern "C" + +#endif // BUILD_EMBEDDED_APP + +#endif // PLATFORM_IOS diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/UnrealBridge.h b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/UnrealBridge.h index 4448e34..bec1757 100644 --- a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/UnrealBridge.h +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/UnrealBridge.h @@ -113,8 +113,27 @@ UNREALBRIDGE_API int32_t UnrealBridge_GetQualitySettings(int32_t* outValues, /// Bring up the embedded engine plumbing. Safe to call more than once; only /// the first call does anything. +/// +/// This sets up messaging only. It does not start the engine. UNREALBRIDGE_API void UnrealBridge_Init(void); +/// Start Unreal. Call once, from the main thread, after UnrealBridge_Init. +/// +/// In an embedded build Unreal's own launch path never runs, because the host +/// owns main() and the app delegate. This is the replacement: it starts the +/// game thread, after which the engine boots and eventually announces that a +/// render view can be made. +/// +/// Two hard requirements, both enforced by the engine rather than by us: +/// +/// - The application's delegate must be, or subclass, IOSAppDelegate. Unreal +/// logs this Fatal: "Currently, a native app embedding Unreal must have the +/// AppDelegate subclass from IOSAppDelegate." +/// - It must be called on the main thread, before any view is requested. +/// +/// Returns non-zero if the engine was started. +UNREALBRIDGE_API int32_t UnrealBridge_StartEngine(void); + /// Advance the engine by [deltaSeconds]. Call from the thread that owns the /// engine, once per frame. Returns non-zero if the engine did work and wants to /// be ticked again promptly. @@ -139,6 +158,71 @@ UNREALBRIDGE_API void UnrealBridge_AllowSleep(const char* requester); UNREALBRIDGE_API int32_t UnrealBridge_IsAwakeForTicking(void); UNREALBRIDGE_API int32_t UnrealBridge_IsAwakeForRendering(void); +// ============================================================ +// MARK: - Rendering surface +// ============================================================ +// +// iOS only. Unreal's embedded mode expects the host to create the view the +// engine renders into and hand it over, which LaunchIOS.cpp states directly: +// "For embedded apps, the UEEmbeddedView must have been created and set into +// the AppDelegate as IOSView". +// +// So the framework builds an FIOSView, registers it with the app delegate, and +// returns it here as an opaque pointer. The host casts it to UIView* and puts +// it in its own hierarchy, which is how it ends up inside a Flutter widget. +// Unreal renders into the view's CAMetalLayer directly, so nothing is copied +// per frame. +// +// macOS has no equivalent. bShouldCompileAsDLL does not define +// BUILD_EMBEDDED_APP there and no Mac runtime code honours it, so these return +// NULL and do nothing. + +/// Fired once the engine has loaded its config and can build a render view. +/// +/// Unreal announces this itself: FAppEntry broadcasts an "inisareready" command +/// on the embedded-to-native channel, with a comment saying it means "the View +/// can be made if it was waiting to create the view". Creating the view before +/// that point is the timing bug this exists to avoid. +/// +/// Fires on the game thread, so marshal before touching UIKit. +typedef void (*UnrealEngineReadyCallback)(void); + +/// Register interest in that signal. If the engine has already announced it, +/// the callback fires immediately rather than never, so a host that registers +/// late is not left waiting. Pass NULL to unregister. +UNREALBRIDGE_API void UnrealBridge_SetEngineReadyCallback( + UnrealEngineReadyCallback callback); + +/// Whether the engine has announced it. Polling alternative to the callback. +UNREALBRIDGE_API int32_t UnrealBridge_IsReadyForView(void); + +/// Create the engine's render view, or return the existing one. +/// +/// Returns NULL until UnrealBridge_IsReadyForView reports non-zero, because the +/// engine has not read the config the view depends on yet. +/// +/// Must be called from the main thread. Returns a UIView* as an opaque +/// pointer, owned by the engine's app delegate: retain it if you need to, but +/// do not release it. Returns NULL on macOS, or if the engine could not make +/// the view. +/// +/// Sizes are in points; [scale] is the display scale, normally +/// UIScreen.main.scale. +UNREALBRIDGE_API void* UnrealBridge_CreateView(float width, float height, + float scale); + +/// Tell the engine the view's size changed. Main thread. +UNREALBRIDGE_API void UnrealBridge_ResizeView(float width, float height, + float scale); + +/// Tear the view down. Main thread. The pointer from UnrealBridge_CreateView is +/// dead after this. +UNREALBRIDGE_API void UnrealBridge_DestroyView(void); + +/// Whether the view exists and its framebuffer is ready for the RHI. Rendering +/// only actually happens once this returns non-zero. +UNREALBRIDGE_API int32_t UnrealBridge_IsViewReady(void); + // ============================================================ // MARK: - Host lifecycle // ============================================================ diff --git a/example/unreal/demo/Source/GameFrameworkProject.Target.cs b/example/unreal/demo/Source/GameFrameworkProject.Target.cs index 57caaca..8ba8a1f 100644 --- a/example/unreal/demo/Source/GameFrameworkProject.Target.cs +++ b/example/unreal/demo/Source/GameFrameworkProject.Target.cs @@ -1,5 +1,8 @@ using UnrealBuildTool; +using System; using System.Collections.Generic; +using System.Diagnostics; +using System.IO; public class GameFrameworkProjectTarget : TargetRules { @@ -12,19 +15,92 @@ public GameFrameworkProjectTarget(TargetInfo Target) : base(Target) // Build as a dynamic library so a Flutter host can link the result. // - // iOS drives this from Config/DefaultEngine.ini instead, where - // bBuildAsFramework under IOSRuntimeSettings makes UEBuildIOS set - // bShouldCompileAsDLL for us. Mac has no equivalent switch, so the - // target asks directly. + // The two platforms ask for it differently. iOS reads bBuildAsFramework + // from Config/DefaultEngine.ini and UEBuildIOS sets bShouldCompileAsDLL + // itself; Mac has no such switch, so the target sets it here. // - // The unique build environment is required: without it UBT refuses, - // because the project would be changing a setting it shares with the - // installed engine's own build products. + // Either way UBT objects, because the target changes a setting shared + // with the engine's own build products, and it does not care whether + // the project or the ini asked for it. bOverrideBuildEnvironment gets + // past that; TargetBuildEnvironment.Unique is refused outright by an + // installed engine. + // + // Read that as permission, not as a fix. An installed engine ships its + // modules prebuilt, so on iOS BUILD_EMBEDDED_APP reaches this project + // and not the engine, and the resulting framework links, launches, and + // never boots an engine. Embedding needs the engine built from source. + // Building a linkable dylib does not. + if (Target.Platform == UnrealTargetPlatform.Mac || + Target.Platform == UnrealTargetPlatform.IOS) + { + bOverrideBuildEnvironment = true; + } + if (Target.Platform == UnrealTargetPlatform.Mac) { LinkType = TargetLinkType.Monolithic; - bOverrideBuildEnvironment = true; bShouldCompileAsDLL = true; } + + if (Target.Platform == UnrealTargetPlatform.IOS) + { + AddSwiftCompatibilityLibraries(Target); + } + } + + /// Link the Swift back-deployment libraries by hand for a framework build. + /// + /// Engine and plugin Swift objects reference __swift_FORCE_LOAD_$_swiftCompatibility56, + /// which lives in a static library shipped inside the Xcode toolchain. For a + /// normal app Xcode drives the final link and adds that path itself. A + /// framework build is linked by UnrealBuildTool directly, and + /// AppleToolChain only adds the system /usr/lib/swift, not the toolchain's + /// static compatibility libraries, so the link fails with undefined symbols. + /// + /// Nothing here is needed once UBT adds the path itself. + private void AddSwiftCompatibilityLibraries(TargetInfo Target) + { + string ToolchainRoot = GetXcodeDeveloperDir(); + if (string.IsNullOrEmpty(ToolchainRoot)) + { + return; + } + + // The simulator has its own copy of these, and linking the device + // ones into a simulator build fails on architecture. + string SwiftPlatformDir = + Target.Architectures.Contains(UnrealArch.IOSSimulator) ? "iphonesimulator" : "iphoneos"; + + string SwiftLibDir = Path.Combine(ToolchainRoot, + "Toolchains", "XcodeDefault.xctoolchain", "usr", "lib", "swift", SwiftPlatformDir); + + if (!Directory.Exists(SwiftLibDir)) + { + return; + } + + AdditionalLinkerArguments = + (AdditionalLinkerArguments ?? "") + + String.Format(" -L\"{0}\" -lswiftCompatibility56 -lswiftCompatibilityConcurrency", SwiftLibDir); + } + + private string GetXcodeDeveloperDir() + { + try + { + ProcessStartInfo Info = new ProcessStartInfo("/usr/bin/xcode-select", "-p"); + Info.RedirectStandardOutput = true; + Info.UseShellExecute = false; + using (Process Proc = Process.Start(Info)) + { + string Output = Proc.StandardOutput.ReadToEnd().Trim(); + Proc.WaitForExit(); + return Proc.ExitCode == 0 ? Output : ""; + } + } + catch (Exception) + { + return ""; + } } } From 4f6b113f1053e84ba4ab2c597bfe0accdb4e8fe5 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Fri, 4 Sep 2026 07:37:08 -0500 Subject: [PATCH 06/25] Fix what a source engine build and a real cook exposed The engine now boots inside the host process, which took getting past several things that only fail once you build and run them. Engine patch, recorded under engines/unreal/patches rather than applied silently: six numeric conversion defects in EmbeddedCommunication.cpp, all inside code that only compiles when BUILD_EMBEDDED_APP is defined, all errors under UE 5.8's warning levels. Five are narrowing. One is a real bug, where ForceTickMin and ForceTickMax are seconds parsed with Atoi, so -ForceTickMin=0.05 became zero. Six defects in one file says how rarely that path gets built. The editor target sat on BuildSettingsVersion.V6 while the installed engine is built with V7 defaults, so UBT refused it for modifying shared settings. Matching the engine is the fix, not overriding it. The game module used FJsonObject without depending on Json or JsonUtilities. A monolithic game target pulls those in through the plugin and links fine, so the omission only surfaced when the modular editor target tried to link and produced a wall of undefined symbols. Cooking needs that editor target, so this blocked content entirely. Where it stands: the framework builds from a source engine, the host links and launches it, and Unreal reads its cooked content and applies device profiles inside the host process. It then crashes before RHI initialisation, with no LogRHI output at all, which is where it wants the render view. The view is currently created on the readiness signal, and that ordering looks wrong against what FAppEntry does while it waits. Not yet resolved. --- ...dedCommunication-numeric-conversions.patch | 49 +++++++++++++++++++ engines/unreal/patches/README.md | 26 ++++++++++ example/unreal/demo/Config/DefaultEngine.ini | 15 ++++++ .../GameFrameworkProject.Build.cs | 7 ++- .../GameFrameworkProjectEditor.Target.cs | 2 +- 5 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 engines/unreal/patches/0001-EmbeddedCommunication-numeric-conversions.patch create mode 100644 engines/unreal/patches/README.md diff --git a/engines/unreal/patches/0001-EmbeddedCommunication-numeric-conversions.patch b/engines/unreal/patches/0001-EmbeddedCommunication-numeric-conversions.patch new file mode 100644 index 0000000..efe7062 --- /dev/null +++ b/engines/unreal/patches/0001-EmbeddedCommunication-numeric-conversions.patch @@ -0,0 +1,49 @@ +diff --git a/Engine/Source/Runtime/Core/Private/Misc/EmbeddedCommunication.cpp b/Engine/Source/Runtime/Core/Private/Misc/EmbeddedCommunication.cpp +index ab2d3043d..4ab849b85 100644 +--- a/Engine/Source/Runtime/Core/Private/Misc/EmbeddedCommunication.cpp ++++ b/Engine/Source/Runtime/Core/Private/Misc/EmbeddedCommunication.cpp +@@ -111,11 +111,11 @@ void FEmbeddedCommunication::ForceTick(int ID, float MinTimeSlice, float MaxTime + FString Override; + if (FParse::Value(FCommandLine::Get(), TEXT("ForceTickMin="), Override)) + { +- OverrideMinTimeSlice = FCString::Atoi(*Override); ++ OverrideMinTimeSlice = FCString::Atof(*Override); + } + if (FParse::Value(FCommandLine::Get(), TEXT("ForceTickMax="), Override)) + { +- OverrideMaxTimeSlice = FCString::Atoi(*Override); ++ OverrideMaxTimeSlice = FCString::Atof(*Override); + } + } + +@@ -139,10 +139,10 @@ void FEmbeddedCommunication::ForceTick(int ID, float MinTimeSlice, float MaxTime + { + UE_LOGF(LogInit, Display, "###ForceTick %d: processing messages...", ID); + //We have to manually tick everything as we are looping the main thread here +- FTSTicker::GetCoreTicker().Tick(Now - LastTime); ++ FTSTicker::GetCoreTicker().Tick((float)(Now - LastTime)); + FThreadManager::Get().Tick(); + +- FPlatformProcess::Sleep(DeltaTime); ++ FPlatformProcess::Sleep((float)DeltaTime); + + // update timer + LastTime = Now; +@@ -414,7 +414,7 @@ bool FEmbeddedCommunication::TickGameThread(float DeltaTime) + if (SleepTimeSeconds > 0.0) + { + UE_LOGF(LogInit, VeryVerbose, "FEmbeddedCommunication Sleeping GameThread for %ls seconds...", *FString::SanitizeFloat(SleepTimeSeconds)); +- const uint32 SleepTimeMilliseconds = 1000 * SleepTimeSeconds; ++ const uint32 SleepTimeMilliseconds = (uint32)(1000.0 * SleepTimeSeconds); + bWasTriggered = GSleepEvent->Wait(SleepTimeMilliseconds); + UE_LOGF(LogInit, VeryVerbose, "FEmbeddedCommunication Woke up. Reason=[%ls]", bWasTriggered ? TEXT("Triggered") : TEXT("TimedOut")); + } +@@ -425,7 +425,7 @@ bool FEmbeddedCommunication::TickGameThread(float DeltaTime) + { + // Sleep for 5 seconds or until triggered + UE_LOGF(LogInit, VeryVerbose, "FEmbeddedCommunication Sleeping GameThread for %ls seconds...", *FString::SanitizeFloat(IdleSleepTimeSeconds)); +- const uint32 IdleSleepTimeMilliseconds = 1000 * IdleSleepTimeSeconds; ++ const uint32 IdleSleepTimeMilliseconds = (uint32)(1000.0 * IdleSleepTimeSeconds); + bool bWasTriggered = GSleepEvent->Wait(IdleSleepTimeMilliseconds); + UE_LOGF(LogInit, VeryVerbose, "FEmbeddedCommunication Woke up. Reason=[%ls]", bWasTriggered ? TEXT("Triggered") : TEXT("TimedOut")); + } diff --git a/engines/unreal/patches/README.md b/engines/unreal/patches/README.md new file mode 100644 index 0000000..b45843c --- /dev/null +++ b/engines/unreal/patches/README.md @@ -0,0 +1,26 @@ +# Engine patches + +Unreal Engine changes needed to build an embedded framework. They apply to a +source build of the engine, since an installed engine ships its modules prebuilt +and cannot honour the settings embedding requires. + +Apply from the root of your engine clone: + +```bash +cd /path/to/UnrealEngine +git apply /path/to/gameframework/engines/unreal/patches/*.patch +``` + +## 0001-EmbeddedCommunication-numeric-conversions + +Six numeric conversion defects in +`Engine/Source/Runtime/Core/Private/Misc/EmbeddedCommunication.cpp`, all inside +code that only compiles when `BUILD_EMBEDDED_APP` is defined. UE 5.8 treats them +as errors, so the file does not build and neither does an embedded target. + +Five are narrowing warnings. One is a real bug: `ForceTickMin` and +`ForceTickMax` are time slices in seconds and were parsed with `FCString::Atoi`, +so `-ForceTickMin=0.05` became zero. They now use `Atof`. + +That six defects sit in one file says something about how rarely this path is +built. Verified against UE 5.8.2. Worth sending upstream. diff --git a/example/unreal/demo/Config/DefaultEngine.ini b/example/unreal/demo/Config/DefaultEngine.ini index 2f1cd38..ffe35e1 100644 --- a/example/unreal/demo/Config/DefaultEngine.ini +++ b/example/unreal/demo/Config/DefaultEngine.ini @@ -27,3 +27,18 @@ bAutomaticSigning=True IOSTeamID=L23W97HT75 bShipForBitcode=False MinimumiOSVersion=15.0 + +[/Script/AndroidFileServerEditor.AndroidFileServerRuntimeSettings] +bEnablePlugin=True +bAllowNetworkConnection=True +SecurityToken=7F175977D44486A0C7A97CAC1552AC63 +bIncludeInShipping=False +bAllowExternalStartInShipping=False +bCompileAFSProject=False +bUseCompression=False +bLogFiles=False +bReportStats=False +ConnectionType=USBOnly +bUseManualIPAddress=False +ManualIPAddress= + diff --git a/example/unreal/demo/Source/GameFrameworkProject/GameFrameworkProject.Build.cs b/example/unreal/demo/Source/GameFrameworkProject/GameFrameworkProject.Build.cs index 833bfde..939a4cf 100644 --- a/example/unreal/demo/Source/GameFrameworkProject/GameFrameworkProject.Build.cs +++ b/example/unreal/demo/Source/GameFrameworkProject/GameFrameworkProject.Build.cs @@ -11,7 +11,12 @@ public GameFrameworkProject(ReadOnlyTargetRules Target) : base(Target) "CoreUObject", "Engine", "InputCore", - "FlutterPlugin" + "FlutterPlugin", + // FlutterGameMode parses and builds JSON directly. A monolithic + // game target pulls these in through the plugin, so the omission + // only shows up when the modular editor target tries to link. + "Json", + "JsonUtilities" }); PrivateDependencyModuleNames.AddRange(new string[] { }); diff --git a/example/unreal/demo/Source/GameFrameworkProjectEditor.Target.cs b/example/unreal/demo/Source/GameFrameworkProjectEditor.Target.cs index 30851f8..6b11400 100644 --- a/example/unreal/demo/Source/GameFrameworkProjectEditor.Target.cs +++ b/example/unreal/demo/Source/GameFrameworkProjectEditor.Target.cs @@ -6,7 +6,7 @@ public class GameFrameworkProjectEditorTarget : TargetRules public GameFrameworkProjectEditorTarget(TargetInfo Target) : base(Target) { Type = TargetType.Editor; - DefaultBuildSettings = BuildSettingsVersion.V6; + DefaultBuildSettings = BuildSettingsVersion.V7; IncludeOrderVersion = EngineIncludeOrderVersion.Latest; ExtraModuleNames.Add("GameFrameworkProject"); } From 6a7e6ddd5742a19d7245487bb62c06fe6aa54852 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 5 Sep 2026 10:29:56 -0500 Subject: [PATCH 07/25] Unreal renders inside a host app on iOS Two things were stopping it, and the second only became findable once the first was fixed. The readiness handshake could never complete. FEngineLoop::PreInit calls PlatformInit around line 2886, which broadcasts "inisareready" and then blocks, spinning until AppDelegate.IOSView exists. Plugin modules for the PreDefault phase do not load until around line 4675, which execution never reaches. So the engine announced readiness before anything in this plugin was alive to hear it, then waited for a view that a host listening for that announcement would never create. Both sides waiting on each other. The engine polls for the view, so the host can just offer one every tick until it takes, and UnrealBridge_CreateView now gates on the engine having been started rather than on an announcement that cannot arrive. Before StartEngine is still refused: Metal comes up during engine startup, and building a view without it crashes, which was worth confirming on a device. With that fixed the engine got through renderer init and stopped at "Failed to load package ''", because the scaffolded GameDefaultMap was empty. It now points at /Engine/Maps/Entry with FlutterGameMode as the default game mode. Verified on an iPhone 16 Pro against a source-built UE 5.8.2: the engine boots, loads the map, brings the world up for play with FlutterGameMode, registers our router, and draws into the FIOSView handed across the C ABI. The default mobile touch interface renders and responds to touch. One caveat worth recording for whoever wires up the Flutter platform view. The engine's view must be inside the visible hierarchy, not merely in the window. A CAMetalLayer behind an opaque view is never composited, its drawables are never released, and the game thread blocks on nextDrawable once the queue fills. That presents as a static screen and an engine stuck at a couple of dozen frames, which looks nothing like a parenting mistake. --- .../Private/IOS/FlutterView_IOS.mm | 40 ++++++++++++++----- example/unreal/demo/Config/DefaultEngine.ini | 9 ++++- .../Private/IOS/FlutterView_IOS.mm | 40 ++++++++++++++----- 3 files changed, 67 insertions(+), 22 deletions(-) diff --git a/engines/unreal/plugin/Source/FlutterPlugin/Private/IOS/FlutterView_IOS.mm b/engines/unreal/plugin/Source/FlutterPlugin/Private/IOS/FlutterView_IOS.mm index b3a3805..998ead9 100644 --- a/engines/unreal/plugin/Source/FlutterPlugin/Private/IOS/FlutterView_IOS.mm +++ b/engines/unreal/plugin/Source/FlutterPlugin/Private/IOS/FlutterView_IOS.mm @@ -40,6 +40,16 @@ /// cleared in UnrealBridge_DestroyView so it cannot outlive the view. static FIOSView* GEmbeddedView = nil; +/// Whether UnrealBridge_StartEngine has been called. +/// +/// FAppEntry blocks the game thread waiting for AppDelegate.IOSView, and it +/// only announces readiness from the main thread once config is loaded. A host +/// that waits for that announcement before building the view is relying on the +/// two crossing in the right order. Creating the view up front is the other +/// way round, so both are allowed: before the engine is started, or after it +/// says it is ready. +static bool GEngineStartRequested = false; + /// Apply the size Unreal should render at. /// /// The engine works in pixels while the host talks in points, so the scale @@ -77,8 +87,7 @@ int32_t UnrealBridge_StartEngine(void) return 0; } - static bool bStarted = false; - if (bStarted) + if (GEngineStartRequested) { return 1; } @@ -90,7 +99,7 @@ int32_t UnrealBridge_StartEngine(void) // // It reaches for [IOSAppDelegate GetDelegate], which is Fatal if the app's // delegate does not subclass IOSAppDelegate. - bStarted = true; + GEngineStartRequested = true; [FIOSView StartupEmbeddedUnreal]; UE_LOG(LogTemp, Log, TEXT("[FlutterView_IOS] Engine start requested")); @@ -113,15 +122,26 @@ int32_t UnrealBridge_StartEngine(void) return nullptr; } - // The engine announces when the config it needs has been read, and the view - // depends on that. Building one earlier is a race, so refuse and let the - // host wait for UnrealBridge_SetEngineReadyCallback instead of getting a - // view that half works. - if (!FlutterBridge_IsEngineReadyForView()) + // Do not wait for the engine to announce readiness. It cannot arrive in + // time, and relying on it deadlocks. + // + // FEngineLoop::PreInit calls FPlatformMisc::PlatformInit (which on iOS is + // FAppEntry::PlatformInit) at around line 2886. That broadcasts + // "inisareready" and then blocks, spinning until AppDelegate.IOSView + // exists. Plugin modules for the PreDefault phase do not load until around + // line 4675, which execution never reaches. So the broadcast happens before + // anything in this plugin is alive to hear it, and the engine then waits + // for a view that a host listening for that broadcast will never create. + // + // The engine polls for the view, so the host can simply make one once the + // engine has been started, and the wait loop picks it up. Before + // StartEngine is too early: Metal comes up as part of engine startup, and + // building a view without it crashes. + if (!GEngineStartRequested) { UE_LOG(LogTemp, Warning, - TEXT("[FlutterView_IOS] Engine has not signalled readiness yet; " - "register UnrealBridge_SetEngineReadyCallback and create the view from there")); + TEXT("[FlutterView_IOS] Call UnrealBridge_StartEngine before creating a view; " + "Metal is not up until the engine starts")); return nullptr; } diff --git a/example/unreal/demo/Config/DefaultEngine.ini b/example/unreal/demo/Config/DefaultEngine.ini index ffe35e1..9d2d9ad 100644 --- a/example/unreal/demo/Config/DefaultEngine.ini +++ b/example/unreal/demo/Config/DefaultEngine.ini @@ -2,8 +2,13 @@ +ActiveGameNameRedirects=(OldGameName="TP_Blank",NewGameName="/Script/GameFrameworkProject") [/Script/EngineSettings.GameMapsSettings] -GameDefaultMap= -EditorStartupMap= +; A scaffolded project has no level of its own, and an empty GameDefaultMap +; makes the engine boot all the way through renderer init and then fail with +; "Failed to load package ''". /Engine/Maps/Entry is the engine's own minimal +; map, so there is something to load until you make your own. +GameDefaultMap=/Engine/Maps/Entry +EditorStartupMap=/Engine/Maps/Entry +GlobalDefaultGameMode=/Script/GameFrameworkProject.FlutterGameMode [/Script/AndroidRuntimeSettings.AndroidRuntimeSettings] PackageName=com.gameframework.project diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/IOS/FlutterView_IOS.mm b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/IOS/FlutterView_IOS.mm index b3a3805..998ead9 100644 --- a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/IOS/FlutterView_IOS.mm +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/IOS/FlutterView_IOS.mm @@ -40,6 +40,16 @@ /// cleared in UnrealBridge_DestroyView so it cannot outlive the view. static FIOSView* GEmbeddedView = nil; +/// Whether UnrealBridge_StartEngine has been called. +/// +/// FAppEntry blocks the game thread waiting for AppDelegate.IOSView, and it +/// only announces readiness from the main thread once config is loaded. A host +/// that waits for that announcement before building the view is relying on the +/// two crossing in the right order. Creating the view up front is the other +/// way round, so both are allowed: before the engine is started, or after it +/// says it is ready. +static bool GEngineStartRequested = false; + /// Apply the size Unreal should render at. /// /// The engine works in pixels while the host talks in points, so the scale @@ -77,8 +87,7 @@ int32_t UnrealBridge_StartEngine(void) return 0; } - static bool bStarted = false; - if (bStarted) + if (GEngineStartRequested) { return 1; } @@ -90,7 +99,7 @@ int32_t UnrealBridge_StartEngine(void) // // It reaches for [IOSAppDelegate GetDelegate], which is Fatal if the app's // delegate does not subclass IOSAppDelegate. - bStarted = true; + GEngineStartRequested = true; [FIOSView StartupEmbeddedUnreal]; UE_LOG(LogTemp, Log, TEXT("[FlutterView_IOS] Engine start requested")); @@ -113,15 +122,26 @@ int32_t UnrealBridge_StartEngine(void) return nullptr; } - // The engine announces when the config it needs has been read, and the view - // depends on that. Building one earlier is a race, so refuse and let the - // host wait for UnrealBridge_SetEngineReadyCallback instead of getting a - // view that half works. - if (!FlutterBridge_IsEngineReadyForView()) + // Do not wait for the engine to announce readiness. It cannot arrive in + // time, and relying on it deadlocks. + // + // FEngineLoop::PreInit calls FPlatformMisc::PlatformInit (which on iOS is + // FAppEntry::PlatformInit) at around line 2886. That broadcasts + // "inisareready" and then blocks, spinning until AppDelegate.IOSView + // exists. Plugin modules for the PreDefault phase do not load until around + // line 4675, which execution never reaches. So the broadcast happens before + // anything in this plugin is alive to hear it, and the engine then waits + // for a view that a host listening for that broadcast will never create. + // + // The engine polls for the view, so the host can simply make one once the + // engine has been started, and the wait loop picks it up. Before + // StartEngine is too early: Metal comes up as part of engine startup, and + // building a view without it crashes. + if (!GEngineStartRequested) { UE_LOG(LogTemp, Warning, - TEXT("[FlutterView_IOS] Engine has not signalled readiness yet; " - "register UnrealBridge_SetEngineReadyCallback and create the view from there")); + TEXT("[FlutterView_IOS] Call UnrealBridge_StartEngine before creating a view; " + "Metal is not up until the engine starts")); return nullptr; } From ce806b594869e317f2d8e362711b1b304f7594c8 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 5 Sep 2026 10:41:56 -0500 Subject: [PATCH 08/25] feat(unreal/ios): drive the engine from the Flutter platform view The startup flow that got Unreal rendering on device now runs behind GameWidget instead of a throwaway host app. Three things changed in the pod. It starts the engine itself, because nothing works until StartEngine runs and that is what brings Metal up. It offers a render view from the display link tick rather than waiting to be told when one can be made. And it takes a keep-awake so the engine actually ticks. The readiness callback is gone. It could never have worked: the engine broadcasts from PreInit and then blocks waiting for a view, while plugin modules only load later in that same PreInit, so by the time anything could subscribe the broadcast has been and gone and the engine is already stuck. Polling from the tick is how the engine expects to be handed a view anyway, and CreateView returning non-NULL is the only readiness signal that means anything. Container resizes now reach the engine. GameEngineContainerView already stretched the engine view on layout, but nobody told the engine its new pixel size, so a rotation left Metal drawing at the startup resolution. That shows up as a blurry or cropped scene rather than as a crash, which is the kind of thing you chase for an afternoon. Engines opt in by overriding engineViewDidResize, and Unity is untouched. Tests follow the new contract: the bridge keeps offering while the engine starts, hands the view to the controller exactly once, and stops asking once it has one. That last one matters, since the poll runs at display link rate. All five were checked by mutation, and the controller handoff had no coverage at all before this. INTEGRATION.md covers the one piece the pod cannot do for you. Unreal insists, as a Fatal, that an embedding app's delegate subclasses IOSAppDelegate, and Flutter apps normally subclass FlutterAppDelegate. You cannot have both superclasses, so the doc gives you the runtime approach that was verified on device. --- .../unreal/dart/ios/Classes/UnrealBridge.mm | 47 ++++++--- .../ios/Classes/UnrealEngineController.swift | 37 ++++++++ engines/unreal/dart/ios/INTEGRATION.md | 95 +++++++++++++++++++ .../dart/ios/Tests/UnrealBridgeTests.mm | 40 ++++++-- .../Classes/Core/GameEngineController.swift | 21 +++- 5 files changed, 217 insertions(+), 23 deletions(-) create mode 100644 engines/unreal/dart/ios/INTEGRATION.md diff --git a/engines/unreal/dart/ios/Classes/UnrealBridge.mm b/engines/unreal/dart/ios/Classes/UnrealBridge.mm index 8572515..b2fa27d 100644 --- a/engines/unreal/dart/ios/Classes/UnrealBridge.mm +++ b/engines/unreal/dart/ios/Classes/UnrealBridge.mm @@ -50,6 +50,7 @@ typedef void (*ApplyQualitySettingsFn)(int32_t, int32_t, int32_t, int32_t, typedef void (*AllowSleepFn)(const char*); typedef void (*EngineReadyCallback)(void); typedef void (*SetEngineReadyCallbackFn)(EngineReadyCallback); +typedef int32_t (*StartEngineFn)(void); typedef int32_t (*IsReadyForViewFn)(void); typedef void* (*CreateViewFn)(float, float, float); typedef void (*ResizeViewFn)(float, float, float); @@ -196,7 +197,19 @@ static void HandleUnrealBinary(const char* target, const char* method, // Ticking happens on the main thread. That is where the host lives, and where // an embedded engine expects to be driven from. +// Defined below, once the UnrealBridge class exists. +static void BuildViewNowThatEngineIsReady(void); +static BOOL HasEngineView(void); + static void UnrealTick(double deltaSeconds) { + // The engine blocks in PreInit polling for AppDelegate.IOSView, so keep + // offering one until it takes. This is also the only workable moment: too + // early and Metal is not up, and the readiness announcement that would + // otherwise tell us cannot reach a plugin that has not loaded yet. + if (!HasEngineView()) { + BuildViewNowThatEngineIsReady(); + } + TickFn tick = UNREAL_FN(TickFn, "UnrealBridge_Tick"); if (tick) { tick((float)deltaSeconds); @@ -251,15 +264,6 @@ static void StopTicking(void) { // when that is done. Asking earlier gets NULL, so the bridge registers for the // signal and builds the view when it lands rather than guessing at a delay. -static void BuildViewNowThatEngineIsReady(void); - -/// Fires on the game thread, so hop to main before touching UIKit. -static void OnEngineReady(void) { - dispatch_async(dispatch_get_main_queue(), ^{ - BuildViewNowThatEngineIsReady(); - }); -} - // ============================================================ // MARK: - UnrealBridge // ============================================================ @@ -322,11 +326,22 @@ - (BOOL)createWithConfig:(NSDictionary*)config controller:(id)controller { SetBinaryCallbackFn setBinary = UNREAL_FN(SetBinaryCallbackFn, "UnrealBridge_SetBinaryCallback"); if (setBinary) setBinary(&HandleUnrealBinary); - // Ask to be told when a view can be made. If the engine already announced - // it, this fires straight away rather than never. - SetEngineReadyCallbackFn setReady = - UNREAL_FN(SetEngineReadyCallbackFn, "UnrealBridge_SetEngineReadyCallback"); - if (setReady) setReady(&OnEngineReady); + // Start the engine. Nothing else works until this runs: it is what brings + // Metal up, and the render view cannot be built before it. + // + // Deliberately not waiting for the engine's readiness announcement. It + // broadcasts "inisareready" from PreInit and then blocks waiting for a + // view, and plugin modules only load later in PreInit, so by the time + // anything here could subscribe the announcement has been and gone and the + // engine is already stuck. The view is offered from the tick instead, which + // matches how the engine polls for it. + StartEngineFn startEngine = UNREAL_FN(StartEngineFn, "UnrealBridge_StartEngine"); + if (startEngine) { + NSLog(@"[UnrealBridge] StartEngine -> %d", startEngine()); + } + + KeepAwakeFn keepAwake = UNREAL_FN(KeepAwakeFn, "UnrealBridge_KeepAwake"); + if (keepAwake) keepAwake("flutter", 1); IsReadyFn isReady = UNREAL_FN(IsReadyFn, "UnrealBridge_IsReady"); const BOOL engineReady = isReady && (isReady() != 0); @@ -479,6 +494,10 @@ - (NSDictionary*)getQualitySettings { // MARK: - Deferred view creation // ============================================================ +static BOOL HasEngineView(void) { + return UnrealBridge.shared.engineView != nil; +} + static void BuildViewNowThatEngineIsReady(void) { UnrealBridge* bridge = UnrealBridge.shared; if (bridge.engineView) { diff --git a/engines/unreal/dart/ios/Classes/UnrealEngineController.swift b/engines/unreal/dart/ios/Classes/UnrealEngineController.swift index 043c991..9b6c71b 100644 --- a/engines/unreal/dart/ios/Classes/UnrealEngineController.swift +++ b/engines/unreal/dart/ios/Classes/UnrealEngineController.swift @@ -129,6 +129,11 @@ public class UnrealEngineController: GameEngineController { NSLog("UnrealEngineController: Unreal render view arrived") self.unrealView = view self.attachEngine() + + // Tell the engine the size it is actually rendering at. The container + // resizes the view for us, but the engine works in pixels and will keep + // rendering at its startup guess until it is told otherwise. + self.syncEngineSurfaceSize() self.sendEvent(name: "onMessage", data: [ "target": "Unreal", "method": "onViewReady", @@ -136,6 +141,28 @@ public class UnrealEngineController: GameEngineController { ]) } + public override func engineViewDidResize(to size: CGSize) { + syncEngineSurfaceSize() + } + + /// Push the current container size down to the engine. + /// + /// Sizes cross the bridge in points and the engine works in pixels, so the + /// scale factor is applied on the far side. Safe to call repeatedly, so + /// call it whenever the container changes size. + @objc public func syncEngineSurfaceSize() { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + let size = self.view().bounds.size + guard size.width > 0, size.height > 0 else { return } + + if let bridge = self.getUnrealBridge() { + self.callBridgeResizeView(bridge: bridge, size: size) + } + } + } + public override func attachEngine() { DispatchQueue.main.async { [weak self] in guard let self = self, let unrealView = self.unrealView else { @@ -265,6 +292,16 @@ public class UnrealEngineController: GameEngineController { return shared(bridgeClass, selector) } + private func callBridgeResizeView(bridge: AnyObject, size: CGSize) { + let selector = NSSelectorFromString("resizeViewTo:") + guard bridge.responds(to: selector) else { return } + + let method = bridge.method(for: selector) + typealias ResizeFunc = @convention(c) (AnyObject, Selector, CGSize) -> Void + let resize = unsafeBitCast(method, to: ResizeFunc.self) + resize(bridge, selector, size) + } + private func callBridgeCreate(bridge: AnyObject, config: [String: Any]) -> Bool { let selector = NSSelectorFromString("createWithConfig:controller:") guard bridge.responds(to: selector) else { return false } diff --git a/engines/unreal/dart/ios/INTEGRATION.md b/engines/unreal/dart/ios/INTEGRATION.md new file mode 100644 index 0000000..cf9a65a --- /dev/null +++ b/engines/unreal/dart/ios/INTEGRATION.md @@ -0,0 +1,95 @@ +# Embedding Unreal in a Flutter app on iOS + +The pod handles starting the engine, driving its tick, and handing its render +view to `GameWidget`. One thing it cannot do for you is the app delegate, and +without that the engine will not start at all. + +## Your AppDelegate must subclass IOSAppDelegate + +Unreal states this itself, and as a Fatal rather than a warning: + +> Currently, a native app embedding Unreal must have the AppDelegate subclass +> from IOSAppDelegate. + +`IOSAppDelegate` caches itself in its own `init`, and that cached pointer is how +the engine finds its delegate, its window, and its view. No subclass, no engine. + +The awkward part is that Flutter apps normally subclass `FlutterAppDelegate`, +and an Objective-C class cannot have two superclasses. + +`FlutterAppDelegate` is a convenience, not a requirement. What Flutter actually +needs is a delegate that registers plugins and forwards application lifecycle to +them. So the working shape is a delegate that subclasses `IOSAppDelegate` and +conforms to `FlutterPluginRegistry` and `FlutterAppLifeCycleProvider`. + +## Building the delegate class at runtime + +This is the approach verified on device. It needs no Unreal headers, because the +framework exports the class and you subclass it through the Objective-C runtime. + +Replace your app's `main.m`: + +```objc +#import +#import + +int main(int argc, char* argv[]) { + @autoreleasepool { + Class base = NSClassFromString(@"IOSAppDelegate"); + if (!base) { + // UnrealFramework is not linked. Fall back so the app still runs, + // with no engine. + base = NSClassFromString(@"FlutterAppDelegate"); + } + Class delegate = objc_allocateClassPair(base, "AppDelegate", 0); + objc_registerClassPair(delegate); + return UIApplicationMain(argc, argv, nil, @"AppDelegate"); + } +} +``` + +Then register your plugins from `application:didFinishLaunchingWithOptions:` on +that class, using a category or `class_addMethod`. + +Do not call `super`'s `didFinishLaunchingWithOptions`. In an embedded build it +would start Unreal the non-embedded way and fight with the pod, which starts it +for you. `IOSAppDelegate` caches itself in `init`, which `UIApplicationMain` +already did, so the engine can still find it. + +## What the pod does for you + +Once the delegate is right, `GameWidget(engineType: GameEngineType.unreal)` is +enough. Behind it: + +- `UnrealBridge_StartEngine` runs on create, which is what brings Metal up +- The display link drives `UnrealBridge_Tick` +- The render view is offered to the engine every tick until it takes, then + handed to the controller and added to Flutter's platform view container +- Container size is pushed down so the engine renders at the right resolution + +The pod does not wait for the engine's `inisareready` announcement, and neither +should you. The engine broadcasts it from `PreInit` and then blocks waiting for +a view, while plugin modules only load later in `PreInit`. By the time anything +could subscribe, the announcement has gone and the engine is already stuck. + +## Keep the engine view inside the visible hierarchy + +If you place the view yourself, put it inside a view that is actually on screen, +not merely in the window behind an opaque one. A `CAMetalLayer` that +CoreAnimation never composites never presents, its drawables are never released, +and the game thread blocks on `nextDrawable` once the queue fills. That looks +like a frozen screen and an engine stuck at a couple of dozen frames, which +resembles a rendering bug far more than a view hierarchy mistake. + +`GameWidget` handles this correctly. + +## Requirements + +- An engine built from source. A launcher install cannot define + `BUILD_EMBEDDED_APP` for its own prebuilt modules, so the framework links and + launches and never boots an engine. See `docs/UNREAL_FRAMEWORK_BLOCKER.md` in + game-cli. +- `bBuildAsFramework=True` under `[/Script/IOSRuntimeSettings.IOSRuntimeSettings]` +- Cooked content staged into the app bundle, alongside `uecommandline.txt` +- A level to load. An empty `GameDefaultMap` gets you all the way through + renderer init and then "Failed to load package ''". diff --git a/engines/unreal/dart/ios/Tests/UnrealBridgeTests.mm b/engines/unreal/dart/ios/Tests/UnrealBridgeTests.mm index 14c7973..a67267d 100644 --- a/engines/unreal/dart/ios/Tests/UnrealBridgeTests.mm +++ b/engines/unreal/dart/ios/Tests/UnrealBridgeTests.mm @@ -25,7 +25,13 @@ @interface FakeController : NSObject @property (nonatomic, copy) NSString* gotMethod; @property (nonatomic, copy) NSString* gotData; @property (nonatomic, copy) NSString* gotLevel; +@property (nonatomic, strong) UIView* gotView; @end + +// How many times the bridge handed a view to the controller. This is the +// handoff the Flutter platform view depends on, so it is worth counting rather +// than merely observing. +static int gViewReadyCallbacks = 0; @implementation FakeController - (void)onMessageFromUnrealWithTarget:(NSString*)t method:(NSString*)m data:(NSString*)d { self.gotTarget = t; self.gotMethod = m; self.gotData = d; @@ -33,6 +39,10 @@ - (void)onMessageFromUnrealWithTarget:(NSString*)t method:(NSString*)m data:(NSS - (void)onLevelLoadedWithLevelName:(NSString*)n buildIndex:(NSInteger)i { self.gotLevel = n; } +- (void)onUnrealViewReadyWithView:(UIView*)v { + self.gotView = v; + gViewReadyCallbacks++; +} @end int main(void) { @@ -97,20 +107,34 @@ int main(void) { // A real view, because the bridge stores it weakly and ARC will not // register a weak reference to an arbitrary pointer. UIView* fakeEngineView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 320, 480)]; - MockUnreal_SetView((__bridge void*)fakeEngineView); - // The engine has not announced readiness, so no view should exist yet. - check(gCreateViewCalls == 0, - "no render view is built before the engine signals readiness"); + // Readiness is whatever CreateView returns, not a separate announcement. + // The engine broadcasts readiness from PreInit and then blocks waiting + // for a view, and plugin modules load later in that same PreInit, so + // nothing here can ever hear the broadcast. The bridge polls from the + // tick instead, which is how the engine expects to be handed a view. + // + // Until the engine can make one, CreateView returns NULL and the bridge + // has nothing to show. + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.2]]; + check(gCreateViewCalls > 0, + "the bridge keeps offering to build a view while the engine starts"); check(((id(*)(id, SEL))objc_msgSend)(bridge, NSSelectorFromString(@"getView")) == nil, "getView returns nil while the engine is still starting"); - MockUnreal_SignalEngineReady(); - [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.3]]; - check(gCreateViewCalls == 1, - "the render view is built once the engine announces it can be"); + // Now let the engine hand one back. + MockUnreal_SetView((__bridge void*)fakeEngineView); + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.2]]; check(((id(*)(id, SEL))objc_msgSend)(bridge, NSSelectorFromString(@"getView")) == fakeEngineView, "getView hands back the engine's view once it exists"); + check(gViewReadyCallbacks == 1 && controller.gotView == fakeEngineView, + "the controller is handed that exact view, exactly once"); + + // The poll has to stop, or it runs at display-link rate forever. + const int callsOnceBuilt = gCreateViewCalls; + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.2]]; + check(gCreateViewCalls == callsOnceBuilt, + "the bridge stops asking once it has a view"); ((void(*)(id, SEL))objc_msgSend)(bridge, NSSelectorFromString(@"quit")); check(gStopped == 1, "quit stops the framework"); diff --git a/packages/gameframework/ios/Classes/Core/GameEngineController.swift b/packages/gameframework/ios/Classes/Core/GameEngineController.swift index db53c45..ca1ec00 100644 --- a/packages/gameframework/ios/Classes/Core/GameEngineController.swift +++ b/packages/gameframework/ios/Classes/Core/GameEngineController.swift @@ -6,12 +6,21 @@ import UIKit */ class GameEngineContainerView: UIView { weak var engineView: UIView? - + + /// Fires after the engine view has been stretched to match the container. + /// + /// Engines that render into their own surface have to be told the new size. + /// Resizing the UIView alone leaves them drawing at whatever resolution + /// they started at, which shows up as a blurry or cropped scene after a + /// rotation rather than as an obvious failure. + var onEngineViewResized: ((CGSize) -> Void)? + override func layoutSubviews() { super.layoutSubviews() // Automatically resize engine view to match container bounds if let engineView = engineView, !bounds.isEmpty { engineView.frame = bounds + onEngineViewResized?(bounds.size) } } } @@ -121,8 +130,18 @@ open class GameEngineController: NSObject, GameEnginePlatformView, FlutterStream self.channel.setMethodCallHandler(handleMethodCall) self.eventChannel.setStreamHandler(self) + + // Weakly, because the controller owns the container. + self.containerView.onEngineViewResized = { [weak self] size in + self?.engineViewDidResize(to: size) + } } + /// Called on the main thread whenever the container has resized the engine + /// view. Override it if your engine needs its render surface resized too. + /// The default does nothing. + open func engineViewDidResize(to size: CGSize) {} + // MARK: - Abstract Methods (Override in subclasses) open func createEngine() { From f3b31353e3158a24c3400841742ad7c8fbc6ed24 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 5 Sep 2026 10:57:26 -0500 Subject: [PATCH 09/25] feat(unreal/ios): let host apps subclass IOSAppDelegate directly Unreal will not start unless your app delegate descends from IOSAppDelegate. It says so as a Fatal, and the fallback path Epic wrote for the alternative is behind an #if 0 with a comment saying it is unlikely to work well. So subclassing is the only route, and until now it meant either shipping engine headers to every consumer or hand-editing main.m to build the class at runtime. The pod now ships UnrealAppDelegate.h, which declares just enough of IOSAppDelegate to subclass it. Import it from your bridging header and write `class AppDelegate: IOSAppDelegate`. No engine headers, and the pod itself still links without UnrealFramework, which is checked: nothing in the new code references the class by name, so no undefined symbol shows up in the object file. Redeclaring someone else's class can rot, so the bridge checks it at runtime before starting the engine. It verifies the superclass is still UIResponder, that both redeclared properties are still there, and that your delegate really does descend from IOSAppDelegate. All of it goes through the runtime with the class passed in as an argument, which is what lets the checks be tested without a UIApplication. A spawned test binary has no sharedApplication at all, which we measured rather than assumed. Two things surfaced while testing that the docs would otherwise have got wrong. Swift could not see the window property. Unreal spells it with a capital W, and Swift decides that is the pre-Swift-3 name for UIApplicationDelegate's lowercase window and marks it obsoleted. It comes across as unrealWindow now. Objective-C still sees Window. The worse one: Unreal reads its Window for interface orientation but only assigns it on the startup path an embedded app skips. What fills it in is a naming coincidence, since the Window property generates a setWindow: setter, which is the selector UIKit calls when the storyboard loads. Declare your own window property and you take that selector over, Unreal's stays nil, and orientation goes wrong with nothing reporting an error. The bridge warns about it by name. A warning rather than a refusal, because a scene delegate driven app legitimately has no window here. Both example delegates in INTEGRATION.md were compiled against a stub of the Flutter API before being written down. The first version did not build, since IOSAppDelegate does not conform to FlutterPluginRegistry, so plugins register against the FlutterViewController instead. Eleven new checks, all mutation tested. The mock framework grew an IOSAppDelegate for the same reason the real one exports one. --- .../dart/ios/Classes/UnrealAppDelegate.h | 119 ++++++++++++++++++ .../dart/ios/Classes/UnrealAppDelegate.mm | 118 +++++++++++++++++ .../unreal/dart/ios/Classes/UnrealBridge.mm | 9 ++ engines/unreal/dart/ios/INTEGRATION.md | 99 +++++++++++---- .../dart/ios/Tests/MockUnrealAppDelegate.m | 18 +++ .../dart/ios/Tests/UnrealAppDelegateTests.mm | 119 ++++++++++++++++++ .../dart/ios/Tests/UnrealBridgeAbsentTests.mm | 11 ++ .../unreal/dart/ios/Tests/run_bridge_tests.sh | 17 ++- .../dart/ios/gameframework_unreal.podspec | 6 + 9 files changed, 490 insertions(+), 26 deletions(-) create mode 100644 engines/unreal/dart/ios/Classes/UnrealAppDelegate.h create mode 100644 engines/unreal/dart/ios/Classes/UnrealAppDelegate.mm create mode 100644 engines/unreal/dart/ios/Tests/MockUnrealAppDelegate.m create mode 100644 engines/unreal/dart/ios/Tests/UnrealAppDelegateTests.mm diff --git a/engines/unreal/dart/ios/Classes/UnrealAppDelegate.h b/engines/unreal/dart/ios/Classes/UnrealAppDelegate.h new file mode 100644 index 0000000..1545e4a --- /dev/null +++ b/engines/unreal/dart/ios/Classes/UnrealAppDelegate.h @@ -0,0 +1,119 @@ +// +// UnrealAppDelegate.h +// +// Just enough of Unreal's IOSAppDelegate to subclass it. +// +// Unreal insists, and states it as a Fatal rather than a warning, that an app +// embedding the engine has an app delegate descending from IOSAppDelegate. The +// delegate caches itself in its own init, and that cached pointer is how the +// engine later finds its delegate, its window and its view. No subclass, no +// engine. +// +// The declaration lives in the engine's own headers, which a Flutter app does +// not have and should not need. So the class is redeclared here, narrowly. You +// get to write: +// +// class AppDelegate: IOSAppDelegate { ... } +// +// and the real class, which ships inside UnrealFramework, is what you actually +// subclass at runtime. +// +// Two things to know before you rely on this. +// +// Subclassing needs the symbol at link time, so the app has to link +// UnrealFramework. That is already true for anything embedding Unreal, but it +// does mean this header is not usable in a build without the framework. +// +// This is a redeclaration, so it can drift if Epic changes the class. Nothing +// here is checked by the compiler against the real thing. UnrealAssertAppDelegateUsable() +// checks it at runtime instead, and the bridge calls it before starting the +// engine so drift surfaces as a clear log line rather than as a strange crash +// much later on. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +// Define this before importing if the real engine headers are already in scope, +// otherwise the two declarations collide. +#ifndef UNREAL_HAS_REAL_IOSAPPDELEGATE + +/// Unreal's application delegate. Declared, never defined: the implementation +/// comes from UnrealFramework at link time. +/// +/// The real class conforms to several more protocols (gesture recognisers, Game +/// Center, notifications, text fields). They are left out deliberately, because +/// declaring fewer is safe for subclassing and every extra one is another thing +/// that can drift. The superclass is not optional and must stay UIResponder. +@interface IOSAppDelegate : UIResponder + +/// The engine's window. +/// +/// Unreal spells it with a capital W, and that collides with the lowercase +/// `window` on UIApplicationDelegate: Swift decides the two are the same +/// property under an old name and refuses to let you touch it. So it comes +/// across to Swift as `unrealWindow`, which also keeps it clearly distinct from +/// the UIKit window your own delegate may want. +@property (strong, retain, nonatomic, nullable) UIWindow* Window + NS_SWIFT_NAME(unrealWindow); + +/// The engine's render view. +/// +/// Really an FIOSView, which is a UIView subclass, so reading it as a UIView is +/// always valid. Treat it as read-only. The bridge assigns it while starting the +/// engine, and assigning something that is not an FIOSView will crash the +/// renderer rather than fail politely. +@property (retain, nullable) UIView* IOSView; + +@end + +#endif // UNREAL_HAS_REAL_IOSAPPDELEGATE + +#ifdef __cplusplus +extern "C" { +#endif + +/// Why this engine delegate class and app delegate cannot be used together, or +/// nil when they can. +/// +/// Takes both as arguments rather than looking them up, so the checks can be +/// exercised without a running UIApplication. `appDelegate` may be nil, which +/// means there is nothing to judge and only `engineDelegateClass` is checked. +/// +/// The returned string is meant to be read by a person who is about to have a +/// bad afternoon, so it says what to do, not just what is wrong. +NSString* _Nullable UnrealAppDelegateProblem(Class _Nullable engineDelegateClass, + id _Nullable appDelegate); + +/// Why this app delegate's window arrangement will misbehave, or nil when it +/// looks right. +/// +/// Unreal reads its `Window` for interface orientation, but only assigns it on +/// the startup path an embedded app skips. What fills it in is a naming +/// coincidence: the `Window` property generates a `setWindow:` setter, which is +/// the same selector UIKit calls on the delegate when the storyboard loads. +/// +/// Declare your own `window` property and you take that selector over, Unreal's +/// stays nil, and orientation handling quietly goes wrong. That is a warning +/// rather than a refusal, because an app driving Unreal from a scene delegate +/// legitimately has no window on the app delegate. +NSString* _Nullable UnrealAppDelegateWindowWarning(id _Nullable appDelegate); + +/// Check that the delegate UnrealAppDelegate.h describes matches the one that +/// shipped, and that the running app is actually using it. +/// +/// Returns YES when the engine can start. On NO it has already logged what is +/// wrong and what to do about it. Cheap enough to call on every launch, and the +/// bridge does exactly that. +/// +/// Declared extern "C" so it links the same whether you call it from a .m or a +/// .mm. Without that the bridge, which is ObjC++, would look for a mangled name +/// that an Objective-C caller never emits. +BOOL UnrealAssertAppDelegateUsable(void); + +#ifdef __cplusplus +} +#endif + +NS_ASSUME_NONNULL_END diff --git a/engines/unreal/dart/ios/Classes/UnrealAppDelegate.mm b/engines/unreal/dart/ios/Classes/UnrealAppDelegate.mm new file mode 100644 index 0000000..3afef1a --- /dev/null +++ b/engines/unreal/dart/ios/Classes/UnrealAppDelegate.mm @@ -0,0 +1,118 @@ +// +// UnrealAppDelegate.mm +// +// Runtime check that the IOSAppDelegate declared in UnrealAppDelegate.h still +// matches the one inside UnrealFramework, and that the app is using it. +// +// Nothing here may reference IOSAppDelegate by name in code. Doing that emits a +// link-time reference to the class, and the pod deliberately builds without +// UnrealFramework so it can ship to projects that have no engine. The class +// arrives as an argument instead. +// + +#import "UnrealAppDelegate.h" + +#import +#import + +extern "C" NSString* UnrealAppDelegateProblem(Class engineDelegateClass, id appDelegate) { + if (engineDelegateClass == nil) { + return @"IOSAppDelegate is missing, so UnrealFramework is not loaded. " + @"Check that your plugin vendors UnrealFramework.framework and " + @"that it is embedded in the app bundle."; + } + + // The shim declares IOSAppDelegate : UIResponder. If that ever stops being + // true, every subclass built against the shim has the wrong superclass, and + // the failure would otherwise land somewhere unrecognisable. + Class superclass = class_getSuperclass(engineDelegateClass); + if (superclass != [UIResponder class]) { + return [NSString stringWithFormat: + @"IOSAppDelegate now descends from %s, not UIResponder. " + @"UnrealAppDelegate.h is out of date with this engine build and " + @"subclassing it is no longer safe.", + superclass ? class_getName(superclass) : "nothing"]; + } + + // Getters for the properties the shim redeclares. A missing setter is not + // worth refusing to start over, but a missing getter means the shim is + // describing a class that no longer has that shape. + static const char* const kRequiredGetters[] = {"Window", "IOSView"}; + for (size_t i = 0; i < sizeof(kRequiredGetters) / sizeof(kRequiredGetters[0]); ++i) { + if (![engineDelegateClass instancesRespondToSelector:sel_getUid(kRequiredGetters[i])]) { + return [NSString stringWithFormat: + @"IOSAppDelegate no longer has a '%s' property. " + @"UnrealAppDelegate.h is out of date with this engine build.", + kRequiredGetters[i]]; + } + } + + // No delegate to judge. That happens outside a real app, in a test binary or + // an extension, where UIApplication was never started. The class checks + // above still ran, which is everything that can be known here. + if (appDelegate == nil) { + return nil; + } + + // Declaring the class correctly is not the same as using it. This catches + // the common mistake, which is a delegate still subclassing + // FlutterAppDelegate. + if (![appDelegate isKindOfClass:engineDelegateClass]) { + return [NSString stringWithFormat: + @"Your app delegate is %s, which does not descend from " + @"IOSAppDelegate. Unreal treats that as fatal and will not start. " + @"Subclass IOSAppDelegate instead of FlutterAppDelegate; see " + @"INTEGRATION.md.", + class_getName(object_getClass(appDelegate))]; + } + + return nil; +} + +extern "C" NSString* UnrealAppDelegateWindowWarning(id appDelegate) { + SEL windowGetter = sel_getUid("Window"); + if (appDelegate == nil || ![appDelegate respondsToSelector:windowGetter]) { + return nil; + } + + // -Window is a plain object getter, so messaging it through a typed + // function pointer is safe and avoids a performSelector cast warning. + typedef id (*WindowGetterFn)(id, SEL); + id window = ((WindowGetterFn)objc_msgSend)(appDelegate, windowGetter); + if (window != nil) { + return nil; + } + + return @"Unreal's Window is nil, so it will read the wrong interface " + @"orientation. UIKit sets that window by calling setWindow: on your " + @"delegate, and declaring your own 'window' property takes over that " + @"selector. Remove it and read unrealWindow instead. Ignore this if " + @"you drive Unreal from a scene delegate."; +} + +extern "C" BOOL UnrealAssertAppDelegateUsable(void) { + UIApplication* application = UIApplication.sharedApplication; + + // Outside a real app there is no delegate to check, so check what can be + // checked and let the caller proceed. Inside one, a missing delegate is its + // own problem and worth saying so. + id appDelegate = application.delegate; + if (application != nil && appDelegate == nil) { + NSLog(@"[UnrealAppDelegate] The application has no delegate yet. Start " + @"the engine after the app has finished launching."); + return NO; + } + + NSString* problem = + UnrealAppDelegateProblem(NSClassFromString(@"IOSAppDelegate"), appDelegate); + if (problem != nil) { + NSLog(@"[UnrealAppDelegate] %@", problem); + return NO; + } + + NSString* warning = UnrealAppDelegateWindowWarning(appDelegate); + if (warning != nil) { + NSLog(@"[UnrealAppDelegate] %@", warning); + } + return YES; +} diff --git a/engines/unreal/dart/ios/Classes/UnrealBridge.mm b/engines/unreal/dart/ios/Classes/UnrealBridge.mm index b2fa27d..e02f556 100644 --- a/engines/unreal/dart/ios/Classes/UnrealBridge.mm +++ b/engines/unreal/dart/ios/Classes/UnrealBridge.mm @@ -3,6 +3,8 @@ #import #import +#import "UnrealAppDelegate.h" + // ============================================================ // MARK: - UnrealFramework C ABI // ============================================================ @@ -318,6 +320,13 @@ - (BOOL)createWithConfig:(NSDictionary*)config controller:(id)controller { return NO; } + // Unreal treats a delegate that does not descend from IOSAppDelegate as + // fatal, and it would crash somewhere far less obvious than here. Check it + // while there is still something useful to say about it. + if (!UnrealAssertAppDelegateUsable()) { + return NO; + } + GUnrealEngineController = controller; SetMessageCallbackFn setMessage = UNREAL_FN(SetMessageCallbackFn, "UnrealBridge_SetMessageCallback"); diff --git a/engines/unreal/dart/ios/INTEGRATION.md b/engines/unreal/dart/ios/INTEGRATION.md index cf9a65a..87c8e50 100644 --- a/engines/unreal/dart/ios/INTEGRATION.md +++ b/engines/unreal/dart/ios/INTEGRATION.md @@ -22,39 +22,92 @@ needs is a delegate that registers plugins and forwards application lifecycle to them. So the working shape is a delegate that subclasses `IOSAppDelegate` and conforms to `FlutterPluginRegistry` and `FlutterAppLifeCycleProvider`. -## Building the delegate class at runtime +## Subclass it directly -This is the approach verified on device. It needs no Unreal headers, because the -framework exports the class and you subclass it through the Objective-C runtime. +The pod ships a header that declares `IOSAppDelegate` for you, so you can +subclass it from your own app without any engine headers. -Replace your app's `main.m`: +Add this to `ios/Runner/Runner-Bridging-Header.h`: ```objc -#import -#import - -int main(int argc, char* argv[]) { - @autoreleasepool { - Class base = NSClassFromString(@"IOSAppDelegate"); - if (!base) { - // UnrealFramework is not linked. Fall back so the app still runs, - // with no engine. - base = NSClassFromString(@"FlutterAppDelegate"); +#import +``` + +Then write `ios/Runner/AppDelegate.swift` against it: + +```swift +import UIKit +import Flutter + +@main +class AppDelegate: IOSAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + if let controller = unrealWindow?.rootViewController as? FlutterViewController { + GeneratedPluginRegistrant.register(with: controller) } - Class delegate = objc_allocateClassPair(base, "AppDelegate", 0); - objc_registerClassPair(delegate); - return UIApplicationMain(argc, argv, nil, @"AppDelegate"); + return true } } ``` -Then register your plugins from `application:didFinishLaunchingWithOptions:` on -that class, using a category or `class_addMethod`. +Three things in there are load bearing. + +Do not call `super`. In an embedded build that starts Unreal the non-embedded +way and fights with the pod, which starts it for you. `IOSAppDelegate` caches +itself during `init`, which has already run by this point, so the engine still +finds its delegate. + +Register plugins against the `FlutterViewController`, not against `self`. +Normally `GeneratedPluginRegistrant.register(with: self)` works because +`FlutterAppDelegate` conforms to `FlutterPluginRegistry`, and you no longer +inherit from it. `FlutterViewController` conforms too, and your storyboard +already creates one as the root view controller. + +**Do not declare a `window` property.** This is the one that will cost you an +afternoon, so it gets its own section. + +### The window trap + +Unreal reads its own `Window` to work out the interface orientation. It only +assigns that window on the startup path you just skipped, so you would expect it +to be nil. It is not, and the reason is a naming coincidence. + +Unreal spells the property with a capital W. Objective-C builds a setter from +that by capitalising the first letter, giving `setWindow:`. That is the exact +selector UIKit calls on your app delegate when the storyboard loads its window. +So UIKit hands Unreal its window without either side knowing about the other. + +Declare `var window: UIWindow?` in your delegate and you take that selector +over. Unreal's `Window` stays nil, orientation goes wrong, and nothing anywhere +reports an error. Leave it out and read `unrealWindow` when you need it. + +The bridge checks for this before starting the engine and logs a warning naming +the setter, so you do not have to remember. It is a warning rather than a +refusal, because an app driving Unreal from a scene delegate legitimately has no +window on the app delegate. + +### Two more things worth knowing + +Subclassing needs the class at link time, so the app has to link +UnrealFramework. That is already true for anything embedding Unreal, but it +means this header is no use in a build without the framework. + +The header redeclares a class Epic owns, and nothing checks that redeclaration +against the real one at compile time. So the bridge checks it at runtime +instead, before starting the engine, and refuses with a clear log line if the +shape has drifted or if your delegate does not descend from `IOSAppDelegate` +after all. Call `UnrealAssertAppDelegateUsable()` yourself if you want to fail +earlier. + +### Naming -Do not call `super`'s `didFinishLaunchingWithOptions`. In an embedded build it -would start Unreal the non-embedded way and fight with the pod, which starts it -for you. `IOSAppDelegate` caches itself in `init`, which `UIApplicationMain` -already did, so the engine can still find it. +| Objective-C | Swift | What it is | +|---|---|---| +| `Window` | `unrealWindow` | Unreal's window. Renamed because the capital W collides with `UIApplicationDelegate`'s `window`, and Swift otherwise decides the two are one property under an old name and refuses to let you touch it. | +| `IOSView` | `iosView` | Unreal's render view. Read only. | ## What the pod does for you diff --git a/engines/unreal/dart/ios/Tests/MockUnrealAppDelegate.m b/engines/unreal/dart/ios/Tests/MockUnrealAppDelegate.m new file mode 100644 index 0000000..db5f8a3 --- /dev/null +++ b/engines/unreal/dart/ios/Tests/MockUnrealAppDelegate.m @@ -0,0 +1,18 @@ +/* + * IOSAppDelegate, as the real UnrealFramework exports it. + * + * Shaped to match what UnrealAppDelegate.h redeclares: descends from + * UIResponder, and carries the Window and IOSView properties. The bridge + * refuses to start the engine without this class, so the mock framework has to + * provide it for the same reason the real one does. + */ + +#import + +@interface IOSAppDelegate : UIResponder +@property (strong, retain, nonatomic) UIWindow* Window; +@property (retain) UIView* IOSView; +@end + +@implementation IOSAppDelegate +@end diff --git a/engines/unreal/dart/ios/Tests/UnrealAppDelegateTests.mm b/engines/unreal/dart/ios/Tests/UnrealAppDelegateTests.mm new file mode 100644 index 0000000..7fbd7f1 --- /dev/null +++ b/engines/unreal/dart/ios/Tests/UnrealAppDelegateTests.mm @@ -0,0 +1,119 @@ +// +// Exercise the app delegate contract check. +// +// UnrealAppDelegateProblem takes the engine class and the app delegate as +// arguments precisely so this can run without a UIApplication, which a spawned +// test binary does not have. Every branch below is reachable here; the only +// untested part is UnrealAssertAppDelegateUsable's two-line wiring. +// + +#import +#import "../Classes/UnrealAppDelegate.h" + +static int gFailures = 0; + +static void check(BOOL condition, const char* what) { + printf("%s: %s\n", condition ? "PASS" : "FAIL", what); + if (!condition) gFailures++; +} + +/// Shaped like the real IOSAppDelegate: descends from UIResponder and has both +/// properties the shim redeclares. +@interface FakeEngineDelegate : UIResponder +@property (retain) UIWindow* Window; +@property (retain) UIView* IOSView; +@end +@implementation FakeEngineDelegate +@end + +/// What a correctly written host app delegate looks like. +@interface GoodAppDelegate : FakeEngineDelegate +@end +@implementation GoodAppDelegate +@end + +/// Stands in for a delegate still subclassing FlutterAppDelegate. +@interface UnrelatedAppDelegate : UIResponder +@end +@implementation UnrelatedAppDelegate +@end + +/// Epic changing the superclass out from under the shim. +@interface WrongSuperclassDelegate : NSObject +@end +@implementation WrongSuperclassDelegate +@end + +/// Epic dropping a property the shim redeclares. +@interface MissingPropertyDelegate : UIResponder +@end +@implementation MissingPropertyDelegate +@end + +static BOOL mentions(NSString* haystack, NSString* needle) { + return haystack != nil && + [haystack rangeOfString:needle].location != NSNotFound; +} + +int main(void) { + @autoreleasepool { + NSString* problem; + + problem = UnrealAppDelegateProblem(nil, nil); + check(mentions(problem, @"UnrealFramework"), + "a missing IOSAppDelegate is reported as a missing framework"); + + problem = UnrealAppDelegateProblem([WrongSuperclassDelegate class], nil); + check(mentions(problem, @"UIResponder"), + "a changed superclass is refused, because subclassing is then unsafe"); + + problem = UnrealAppDelegateProblem([MissingPropertyDelegate class], nil); + check(mentions(problem, @"Window"), + "a dropped property is refused and named"); + + problem = UnrealAppDelegateProblem([FakeEngineDelegate class], nil); + check(problem == nil, + "a well shaped class passes when there is no delegate to judge"); + + // The case that actually matters: the app delegate is a subclass, which + // is what a host app writes. + GoodAppDelegate* good = [GoodAppDelegate new]; + problem = UnrealAppDelegateProblem([FakeEngineDelegate class], good); + check(problem == nil, + "a delegate subclassing the engine's delegate is accepted"); + + // And the mistake this check exists to catch. + UnrelatedAppDelegate* wrong = [UnrelatedAppDelegate new]; + problem = UnrealAppDelegateProblem([FakeEngineDelegate class], wrong); + check(mentions(problem, @"IOSAppDelegate") && + mentions(problem, @"UnrelatedAppDelegate"), + "a delegate that does not descend from IOSAppDelegate is refused, and named"); + + // An exact instance, rather than a subclass, is also fine. + FakeEngineDelegate* exact = [FakeEngineDelegate new]; + problem = UnrealAppDelegateProblem([FakeEngineDelegate class], exact); + check(problem == nil, "an exact instance of the engine's delegate is accepted"); + + // The window trap: Unreal reads its Window for orientation, and a host + // that declares its own 'window' property steals the setter that fills + // it in. + check(UnrealAppDelegateWindowWarning(nil) == nil, + "no window warning when there is no delegate to inspect"); + + UnrelatedAppDelegate* noWindowProperty = [UnrelatedAppDelegate new]; + check(UnrealAppDelegateWindowWarning(noWindowProperty) == nil, + "no window warning for a delegate with no Window property at all"); + + GoodAppDelegate* windowless = [GoodAppDelegate new]; + check(mentions(UnrealAppDelegateWindowWarning(windowless), @"setWindow:"), + "a nil Window is reported, naming the setter that should have filled it"); + + GoodAppDelegate* windowed = [GoodAppDelegate new]; + windowed.Window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 10, 10)]; + check(UnrealAppDelegateWindowWarning(windowed) == nil, + "no window warning once Unreal's Window is set"); + + printf("\n%s (%d failures)\n", gFailures ? "FAILED" : "ALL PASSED", gFailures); + return gFailures ? 1 : 0; + } +} diff --git a/engines/unreal/dart/ios/Tests/UnrealBridgeAbsentTests.mm b/engines/unreal/dart/ios/Tests/UnrealBridgeAbsentTests.mm index 5b63515..8d69ea3 100644 --- a/engines/unreal/dart/ios/Tests/UnrealBridgeAbsentTests.mm +++ b/engines/unreal/dart/ios/Tests/UnrealBridgeAbsentTests.mm @@ -2,6 +2,8 @@ #import #import +#import "../Classes/UnrealAppDelegate.h" + int main(void) { @autoreleasepool { Class cls = NSClassFromString(@"UnrealBridge"); @@ -36,6 +38,15 @@ int main(void) { ((void(*)(id, SEL))objc_msgSend)(shared, NSSelectorFromString(@"quit")); printf("PASS: all bridge calls survived with no framework loaded\n"); + + // The delegate check looks the class up by name, so with no framework + // loaded it has to refuse. This is the one path through + // UnrealAssertAppDelegateUsable that a hostless binary can reach. + const BOOL usable = UnrealAssertAppDelegateUsable(); + printf("%s: the delegate check refuses when the framework is absent\n", + usable ? "FAIL" : "PASS"); + if (usable) return 1; + return 0; } } diff --git a/engines/unreal/dart/ios/Tests/run_bridge_tests.sh b/engines/unreal/dart/ios/Tests/run_bridge_tests.sh index ef146f7..8d3634c 100755 --- a/engines/unreal/dart/ios/Tests/run_bridge_tests.sh +++ b/engines/unreal/dart/ios/Tests/run_bridge_tests.sh @@ -15,6 +15,7 @@ set -euo pipefail HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SRC="$HERE/../Classes/UnrealBridge.mm" +DELEGATE_SRC="$HERE/../Classes/UnrealAppDelegate.mm" BUILD="$(mktemp -d)" trap 'rm -rf "$BUILD"' EXIT @@ -49,24 +50,34 @@ compile_mm() { echo "Building bridge..." compile_mm "$SRC" "$BUILD/UnrealBridge.o" +compile_mm "$DELEGATE_SRC" "$BUILD/UnrealAppDelegate.o" echo "Building mock framework..." xcrun --sdk "$SDK" clang -target "$TARGET" -dynamiclib \ -install_name @rpath/MockUnreal.dylib \ - "$HERE/MockUnrealFramework.c" -o "$BUILD/MockUnreal.dylib" + "$HERE/MockUnrealFramework.c" "$HERE/MockUnrealAppDelegate.m" \ + -framework Foundation -framework UIKit -o "$BUILD/MockUnreal.dylib" echo echo "=== Framework absent ===" compile_mm "$HERE/UnrealBridgeAbsentTests.mm" "$BUILD/absent.o" xcrun --sdk "$SDK" clang++ -target "$TARGET" \ - "$BUILD/UnrealBridge.o" "$BUILD/absent.o" \ + "$BUILD/UnrealBridge.o" "$BUILD/UnrealAppDelegate.o" "$BUILD/absent.o" \ -framework Foundation -framework UIKit -framework QuartzCore -o "$BUILD/absent" xcrun simctl spawn "$UDID" "$BUILD/absent" 2>&1 | grep -v '^20[0-9][0-9]-' +echo +echo "=== App delegate contract ===" +compile_mm "$HERE/UnrealAppDelegateTests.mm" "$BUILD/delegate.o" +xcrun --sdk "$SDK" clang++ -target "$TARGET" \ + "$BUILD/UnrealAppDelegate.o" "$BUILD/delegate.o" \ + -framework Foundation -framework UIKit -o "$BUILD/delegate" +xcrun simctl spawn "$UDID" "$BUILD/delegate" 2>&1 | grep -v '^20[0-9][0-9]-' + echo echo "=== Framework present (mock) ===" compile_mm "$HERE/UnrealBridgeTests.mm" "$BUILD/live.o" xcrun --sdk "$SDK" clang++ -target "$TARGET" \ - "$BUILD/UnrealBridge.o" "$BUILD/live.o" "$BUILD/MockUnreal.dylib" \ + "$BUILD/UnrealBridge.o" "$BUILD/UnrealAppDelegate.o" "$BUILD/live.o" "$BUILD/MockUnreal.dylib" \ -framework Foundation -framework UIKit -framework QuartzCore -rpath "$BUILD" -o "$BUILD/live" xcrun simctl spawn "$UDID" "$BUILD/live" 2>&1 | grep -v '^20[0-9][0-9]-' diff --git a/engines/unreal/dart/ios/gameframework_unreal.podspec b/engines/unreal/dart/ios/gameframework_unreal.podspec index 45044dc..a49f2d7 100644 --- a/engines/unreal/dart/ios/gameframework_unreal.podspec +++ b/engines/unreal/dart/ios/gameframework_unreal.podspec @@ -21,6 +21,12 @@ to sync your Unreal export to your plugin's ios/ directory. s.author = { 'xraph' => 'rex@xraph.com' } s.source = { :path => '.' } s.source_files = 'Classes/**/*' + + # The only header a host app is meant to import. It declares Unreal's + # IOSAppDelegate so an app delegate can subclass it, which the engine requires. + # Reach it from Runner-Bridging-Header.h as: + # #import + s.public_header_files = 'Classes/UnrealAppDelegate.h' s.dependency 'Flutter' s.dependency 'gameframework' s.platform = :ios, '15.0' From f88de248d489e1daf0043af6ff666725e3727d46 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 5 Sep 2026 20:48:47 -0500 Subject: [PATCH 10/25] fix(unreal): deliver messages from Flutter to the actors waiting for them Every control in the demo did nothing, and every layer reported success. Dart sent, Swift dispatched, the pod handed the message to the framework, the bridge logged "Received from Flutter", and the cube logged that it had registered. Four boundaries crossed correctly, and then this: void AFlutterBridge::ReceiveFromFlutter(...) { UE_LOG(...); OnMessageFromFlutter(Target, Method, Data); } A Blueprint event and nothing else. UFlutterMessageRouter::RouteMessage exists, every AFlutterActor registers itself with the router by name and waits to be called through it, and nothing ever called it. A project without Blueprints receives nothing at all. There was no error anywhere because nothing failed. A message with no route is only a problem if somebody checks, and nobody did, so it now warns and names the target it could not reach. The rest is what it took to get an embedded engine far enough to notice. Ticking. The host's tick is a display link on the main thread, not Unreal's game loop, and it is not safe to treat it as one. It runs before the engine has loaded its inis, where FEmbeddedCommunication::TickGameThread reads a setting through GConfig without checking it, so the tick guards on that. Anything needing the real game thread goes through a core ticker instead: marshalling through RunOnGameThread does not help, because TickGameThread runs queued work on whoever calls it, which is that same main thread. Changing the resolution from there aborts the process inside the CVar write. The render target. The engine builds its viewport before the host's view exists, at a default 1280x720, and nothing corrects it. It then renders that 16:9 frame into a correctly sized portrait surface, which looks like the scene has been cropped rather than like a render target of the wrong shape. Resizing the scene viewport is what moves it. Asking for a resolution change does not: the console manager refuses the r.SetRes write on priority grounds and says so in the log. Startup. The engine has to start during didFinishLaunching, because IOSAppDelegate reads the command line when the app becomes active and starting the engine is what sets it. The tick has to start with it, or the engine blocks waiting for a view that only the tick offers while Flutter waits for a frame it cannot produce. Also: both podspecs now vendor their framework when it is sitting next to them, which is the case after a sync with no separate game plugin in between. Without that nothing embeds it, the app launches, and the class is missing at runtime. --- .gitignore | 6 ++ .../dart/ios/gameframework_unity.podspec | 9 +- .../dart/ios/Classes/UnrealAppDelegate.h | 14 +++ .../dart/ios/Classes/UnrealAppDelegate.mm | 37 +++++++ .../unreal/dart/ios/Classes/UnrealBridge.mm | 53 +++++++++- .../unreal/dart/ios/Tests/run_bridge_tests.sh | 4 +- .../dart/ios/gameframework_unreal.podspec | 25 ++++- .../FlutterPlugin/Private/FlutterBridge.cpp | 22 ++++ .../Private/FlutterBridge_Apple.cpp | 16 +++ .../Private/IOS/FlutterView_IOS.mm | 100 +++++++++++++++++- .../Classes/Core/GameEngineController.swift | 6 ++ 11 files changed, 280 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index b130159..5b3b33c 100644 --- a/.gitignore +++ b/.gitignore @@ -288,3 +288,9 @@ app.*.symbols # Keep empty directories with .gitkeep !**/.gitkeep + +# Unreal export outputs. Cooked content is large and rebuilt by +# "game export unreal", so it does not belong in the repository. +engines/unreal/dart/ios/UnrealContent/ +engines/unreal/dart/macos/UnrealContent/ +example/unreal/demo_exports/ diff --git a/engines/unity/dart/ios/gameframework_unity.podspec b/engines/unity/dart/ios/gameframework_unity.podspec index 720c02e..a617dd6 100644 --- a/engines/unity/dart/ios/gameframework_unity.podspec +++ b/engines/unity/dart/ios/gameframework_unity.podspec @@ -32,8 +32,13 @@ to sync your Unity export to your plugin's ios/ directory. unity_framework_path = File.join(__dir__, 'UnityFramework.framework') if File.exist?(unity_framework_path) || File.symlink?(unity_framework_path) s.preserve_paths = 'UnityFramework.framework', 'UnityFramework.framework/Data' - # Don't vendor - let the consumer plugin vendor it to avoid conflicts - # s.ios.vendored_frameworks = 'UnityFramework.framework' + + # Vendor it when it is sitting right here, which is the case after + # "game sync unity -p ios" with no separate game plugin in between. Without + # this nothing embeds the framework and the Swift compiler cannot find the + # module. A consumer plugin that vendors its own build syncs there instead, + # so this stays false for them and there is no duplicate. + s.ios.vendored_frameworks = 'UnityFramework.framework' end # Configure framework search paths to find UnityFramework from sibling pods diff --git a/engines/unreal/dart/ios/Classes/UnrealAppDelegate.h b/engines/unreal/dart/ios/Classes/UnrealAppDelegate.h index 1545e4a..918df28 100644 --- a/engines/unreal/dart/ios/Classes/UnrealAppDelegate.h +++ b/engines/unreal/dart/ios/Classes/UnrealAppDelegate.h @@ -100,6 +100,20 @@ NSString* _Nullable UnrealAppDelegateProblem(Class _Nullable engineDelegateClass /// legitimately has no window on the app delegate. NSString* _Nullable UnrealAppDelegateWindowWarning(id _Nullable appDelegate); +/// Start Unreal. Call this from your app delegate's +/// application:didFinishLaunchingWithOptions:, after calling super. +/// +/// This cannot wait until a GameWidget appears. Unreal's own +/// -handleDidBecomeActive reads the command line, and in a non-shipping build +/// that is Fatal if nothing has set it yet. Starting the engine is what sets +/// it, so an app that becomes active before showing a GameWidget dies on launch +/// with a stack that points at UIKit rather than at anything you wrote. +/// +/// Safe to call more than once; the engine only starts the first time. Returns +/// NO when the framework is absent or the delegate is unusable, having already +/// logged why. +BOOL UnrealStartEngineAtLaunch(void); + /// Check that the delegate UnrealAppDelegate.h describes matches the one that /// shipped, and that the running app is actually using it. /// diff --git a/engines/unreal/dart/ios/Classes/UnrealAppDelegate.mm b/engines/unreal/dart/ios/Classes/UnrealAppDelegate.mm index 3afef1a..63075de 100644 --- a/engines/unreal/dart/ios/Classes/UnrealAppDelegate.mm +++ b/engines/unreal/dart/ios/Classes/UnrealAppDelegate.mm @@ -15,6 +15,43 @@ #import #import +#include + +/// Defined in UnrealBridge.mm. +extern "C" void UnrealBridgeBeginDrivingEngine(void); +#import + +extern "C" BOOL UnrealStartEngineAtLaunch(void) { + if (!UnrealAssertAppDelegateUsable()) { + return NO; + } + + // Resolved at runtime rather than linked, so the pod still builds for + // projects with no engine. Same reason the rest of the bridge does it. + typedef int32_t (*StartEngineFn)(void); + StartEngineFn startEngine = + (StartEngineFn)dlsym(RTLD_DEFAULT, "UnrealBridge_StartEngine"); + if (startEngine == NULL) { + NSLog(@"[UnrealAppDelegate] UnrealFramework has no UnrealBridge_StartEngine, " + @"so it was built without the Flutter plugin. Add it under " + @"Plugins/FlutterPlugin and package again."); + return NO; + } + + const int32_t started = startEngine(); + NSLog(@"[UnrealAppDelegate] Engine start at launch -> %d", started); + if (started == 0) { + return NO; + } + + // The engine is now blocking until it is handed a view, and the tick is + // what offers one. It has to start here rather than when a GameWidget + // appears, because that widget's engine call runs in a post-frame callback + // and Flutter cannot produce that frame while the engine is blocked. + UnrealBridgeBeginDrivingEngine(); + return YES; +} + extern "C" NSString* UnrealAppDelegateProblem(Class engineDelegateClass, id appDelegate) { if (engineDelegateClass == nil) { return @"IOSAppDelegate is missing, so UnrealFramework is not loaded. " diff --git a/engines/unreal/dart/ios/Classes/UnrealBridge.mm b/engines/unreal/dart/ios/Classes/UnrealBridge.mm index e02f556..a0ab100 100644 --- a/engines/unreal/dart/ios/Classes/UnrealBridge.mm +++ b/engines/unreal/dart/ios/Classes/UnrealBridge.mm @@ -2,6 +2,7 @@ #import #import +#import #import "UnrealAppDelegate.h" @@ -202,6 +203,7 @@ static void HandleUnrealBinary(const char* target, const char* method, // Defined below, once the UnrealBridge class exists. static void BuildViewNowThatEngineIsReady(void); static BOOL HasEngineView(void); +static void LogEngineViewGeometry(NSString* when, UIView* view); static void UnrealTick(double deltaSeconds) { // The engine blocks in PreInit polling for AppDelegate.IOSView, so keep @@ -252,6 +254,17 @@ static void StartTicking(void) { NSLog(@"[UnrealBridge] Ticking the engine from the display link"); } +/// Begin driving the engine before any widget exists. +/// +/// The engine blocks during startup waiting to be handed a view, and the tick +/// is what offers one. Leaving that until a GameWidget appears deadlocks: the +/// widget's engine call runs in a post-frame callback, and Flutter cannot +/// produce that frame while the engine is still blocking. So whoever starts the +/// engine has to start the tick with it. +extern "C" void UnrealBridgeBeginDrivingEngine(void) { + StartTicking(); +} + static void StopTicking(void) { if (!GDisplayLink) return; [GDisplayLink invalidate]; @@ -400,6 +413,7 @@ - (void)resizeViewTo:(CGSize)size { if (!resize) return; _requestedViewSize = size; resize((float)size.width, (float)size.height, (float)UIScreen.mainScreen.scale); + LogEngineViewGeometry(@"resized", self.engineView); } - (BOOL)isViewReady { @@ -435,6 +449,7 @@ - (void)sendMessageWithTarget:(NSString*)target method:(NSString*)method data:(N return; } send(target.UTF8String, method.UTF8String, data.UTF8String ?: ""); + NSLog(@"[UnrealBridge] handed %@.%@ to the framework", target, method); } - (void)sendBinaryWithTarget:(NSString*)target method:(NSString*)method data:(NSData*)data { @@ -503,6 +518,36 @@ - (NSDictionary*)getQualitySettings { // MARK: - Deferred view creation // ============================================================ +/// Report what the engine's view actually is on screen. +/// +/// A scene that renders as a band across the middle can be either of two very +/// different things: a correctly sized view holding a letterboxed frame, or a +/// view that is itself only that tall. The numbers tell you which, and nothing +/// else does. +static void LogEngineViewGeometry(NSString* when, UIView* view) { + if (view == nil) { + NSLog(@"[UnrealBridge] geometry (%@): no view", when); + return; + } + + // Built rather than CGSizeZero, which is a linked constant and would drag + // CoreGraphics into every target that includes this file. + CGSize drawable = CGSizeMake(0.0, 0.0); + if ([view.layer isKindOfClass:[CAMetalLayer class]]) { + drawable = ((CAMetalLayer*)view.layer).drawableSize; + } + + NSLog(@"[UnrealBridge] geometry (%@): frame=%@ bounds=%@ superview=%@ " + @"layer=%@ drawable=%@ scale=%.1f", + when, + NSStringFromCGRect(view.frame), + NSStringFromCGRect(view.bounds), + view.superview ? NSStringFromCGRect(view.superview.bounds) : @"none", + NSStringFromCGRect(view.layer.frame), + NSStringFromCGSize(drawable), + view.contentScaleFactor); +} + static BOOL HasEngineView(void) { return UnrealBridge.shared.engineView != nil; } @@ -532,6 +577,7 @@ static void BuildViewNowThatEngineIsReady(void) { UIView* view = (__bridge UIView*)handle; bridge.engineView = view; NSLog(@"[UnrealBridge] Render view built at %@", NSStringFromCGSize(size)); + LogEngineViewGeometry(@"built", view); id controller = GUnrealEngineController; SEL selector = NSSelectorFromString(@"onUnrealViewReadyWithView:"); @@ -544,7 +590,10 @@ static void BuildViewNowThatEngineIsReady(void) { [inv setArgument:&arg atIndex:2]; [inv invoke]; } else { - NSLog(@"[UnrealBridge] Controller has no onUnrealViewReady handler; " - @"the view exists but nothing will show it"); + // Expected when the engine starts at launch: the view is ready before + // any GameWidget exists. The controller collects it from getView when + // it does turn up. + NSLog(@"[UnrealBridge] Render view built before a controller existed; " + @"it will be collected when one attaches"); } } diff --git a/engines/unreal/dart/ios/Tests/run_bridge_tests.sh b/engines/unreal/dart/ios/Tests/run_bridge_tests.sh index 8d3634c..308f8a5 100755 --- a/engines/unreal/dart/ios/Tests/run_bridge_tests.sh +++ b/engines/unreal/dart/ios/Tests/run_bridge_tests.sh @@ -70,8 +70,8 @@ echo echo "=== App delegate contract ===" compile_mm "$HERE/UnrealAppDelegateTests.mm" "$BUILD/delegate.o" xcrun --sdk "$SDK" clang++ -target "$TARGET" \ - "$BUILD/UnrealAppDelegate.o" "$BUILD/delegate.o" \ - -framework Foundation -framework UIKit -o "$BUILD/delegate" + "$BUILD/UnrealAppDelegate.o" "$BUILD/UnrealBridge.o" "$BUILD/delegate.o" \ + -framework Foundation -framework UIKit -framework QuartzCore -o "$BUILD/delegate" xcrun simctl spawn "$UDID" "$BUILD/delegate" 2>&1 | grep -v '^20[0-9][0-9]-' echo diff --git a/engines/unreal/dart/ios/gameframework_unreal.podspec b/engines/unreal/dart/ios/gameframework_unreal.podspec index a49f2d7..f48c77b 100644 --- a/engines/unreal/dart/ios/gameframework_unreal.podspec +++ b/engines/unreal/dart/ios/gameframework_unreal.podspec @@ -27,6 +27,22 @@ to sync your Unreal export to your plugin's ios/ directory. # Reach it from Runner-Bridging-Header.h as: # #import s.public_header_files = 'Classes/UnrealAppDelegate.h' + + # Cooked content, shipped into the app bundle root. + # + # Unreal looks for cookeddata and uecommandline.txt next to the executable, so + # they cannot live inside the framework. A flat iOS framework must not carry a + # Resources directory either: installd refuses the whole app when it finds + # one. "game sync unreal -p ios" places these here. + unreal_content_path = File.join(__dir__, 'UnrealContent') + if File.directory?(unreal_content_path) + # Top-level entries, not a glob. A glob matches individual files and + # CocoaPods copies each one to the bundle root, which would flatten + # cookeddata into loose files. Naming the directory copies it whole. + s.resources = Dir.glob(File.join(unreal_content_path, '*')).map do |entry| + File.join('UnrealContent', File.basename(entry)) + end + end s.dependency 'Flutter' s.dependency 'gameframework' s.platform = :ios, '15.0' @@ -40,8 +56,13 @@ to sync your Unreal export to your plugin's ios/ directory. unreal_framework_path = File.join(__dir__, 'UnrealFramework.framework') if File.exist?(unreal_framework_path) || File.symlink?(unreal_framework_path) s.preserve_paths = 'UnrealFramework.framework', 'UnrealFramework.framework/Resources' - # Don't vendor - let the consumer plugin vendor it to avoid conflicts - # s.ios.vendored_frameworks = 'UnrealFramework.framework' + + # Vendor it when it is sitting right here, which is the case after + # "game sync unreal -p ios" with no separate game plugin in between. Without + # this nothing embeds the framework, the app launches, and IOSAppDelegate is + # missing at runtime. A consumer plugin that vendors its own build syncs + # there instead, so this stays false for them and there is no duplicate. + s.ios.vendored_frameworks = 'UnrealFramework.framework' end # Configure framework search paths to find UnrealFramework from sibling pods diff --git a/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge.cpp b/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge.cpp index c2b7077..563e370 100644 --- a/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge.cpp +++ b/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge.cpp @@ -1,6 +1,7 @@ // Copyright Epic Games, Inc. All Rights Reserved. #include "FlutterBridge.h" +#include "FlutterMessageRouter.h" #include "Engine/World.h" #include "Engine/Engine.h" #include "Engine/GameViewportClient.h" @@ -159,6 +160,27 @@ void AFlutterBridge::ReceiveFromFlutter(const FString& Target, const FString& Me { UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] Received from Flutter: Target=%s, Method=%s"), *Target, *Method); + // Hand it to the router, which is the half that reaches C++ actors. + // + // Every AFlutterActor registers itself with the router by name and expects + // messages to arrive that way. Firing only the Blueprint event below means a + // project without Blueprints receives nothing at all: the message crosses + // the channel, reaches the bridge, and stops here, with every log along the + // way reporting success. + bool bRouted = false; + if (UFlutterMessageRouter* Router = UFlutterMessageRouter::Get(this)) + { + bRouted = Router->RouteMessage(Target, Method, Data); + } + + if (!bRouted) + { + UE_LOG(LogTemp, Warning, + TEXT("[FlutterBridge] Nothing is registered for %s, so %s went nowhere. " + "Check GetFlutterTargetName on the actor you meant to reach."), + *Target, *Method); + } + // Fire Blueprint event OnMessageFromFlutter(Target, Method, Data); } diff --git a/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp b/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp index 3e66780..70a553b 100644 --- a/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp +++ b/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp @@ -8,6 +8,7 @@ #include "Async/Async.h" #include "Misc/EmbeddedCommunication.h" #include "Misc/CoreDelegates.h" +#include "Misc/ConfigCacheIni.h" #include @@ -341,6 +342,9 @@ void UnrealBridge_SendToUnreal(const char* Target, const char* Method, const cha TEXT("[FlutterBridge_Apple] Dropping message from Flutter, no bridge actor: Target=%s, Method=%s"), *TargetString, *MethodString); } + + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Apple] Routed %s.%s"), + *TargetString, *MethodString); }); } @@ -511,6 +515,18 @@ void UnrealBridge_Init(void) int32_t UnrealBridge_Tick(float DeltaSeconds) { + // GConfig is null until the engine has loaded its inis, and + // FEmbeddedCommunication::TickGameThread reads a setting through it without + // checking. A host that starts ticking as soon as the engine is asked to + // start gets there first and dereferences null, which it must, because the + // engine blocks during startup waiting to be handed a view and the tick is + // what offers one. + if (GConfig == nullptr) + { + return 0; + } + + // TickGameThread must run on the thread that owns the engine. In an // embedded build that is whichever thread the host drives it from, so this // deliberately does not marshal: doing so would tick from somewhere the diff --git a/engines/unreal/plugin/Source/FlutterPlugin/Private/IOS/FlutterView_IOS.mm b/engines/unreal/plugin/Source/FlutterPlugin/Private/IOS/FlutterView_IOS.mm index 998ead9..0b3ff67 100644 --- a/engines/unreal/plugin/Source/FlutterPlugin/Private/IOS/FlutterView_IOS.mm +++ b/engines/unreal/plugin/Source/FlutterPlugin/Private/IOS/FlutterView_IOS.mm @@ -1,6 +1,13 @@ // Copyright Epic Games, Inc. All Rights Reserved. #include "FlutterBridge.h" +#include "UnrealEngine.h" +#include "Engine/Engine.h" +#include "Containers/Ticker.h" +#include "Engine/GameViewportClient.h" +#include "Slate/SceneViewport.h" + +#include #if PLATFORM_IOS @@ -52,6 +59,83 @@ /// Apply the size Unreal should render at. /// +/// The size the host wants, in pixels, and the size the engine was last told +/// about. +static std::atomic GDesiredPixelWidth{0}; +static std::atomic GDesiredPixelHeight{0}; +static int32 GAppliedPixelWidth = 0; +static int32 GAppliedPixelHeight = 0; +static FTSTicker::FDelegateHandle GResolutionTicker; + +/// Make the engine's render target match the view it renders into. +/// +/// The engine creates its viewport before the host's view exists, at a default +/// 1280x720, and nothing in an embedded build ever corrects it. It then renders +/// that 16:9 frame into a correctly sized portrait surface, which looks like the +/// scene has been cropped into a band rather than like a render target that is +/// the wrong shape. +/// +/// Resizing the scene viewport is what actually moves it. Asking for a +/// resolution change instead does not: the console manager refuses the r.SetRes +/// write on priority grounds and says so in the log, and calling it off the game +/// thread aborts the process inside the CVar change. +/// +/// Compares against the viewport's real size every tick rather than remembering +/// what it last asked for, so it corrects itself if the engine resizes back, and +/// a rotation puts itself right. In the steady state it is one comparison. +static bool ApplyPendingResolution(float) +{ + const int32 Width = GDesiredPixelWidth.load(std::memory_order_acquire); + const int32 Height = GDesiredPixelHeight.load(std::memory_order_acquire); + if (Width <= 0 || Height <= 0) + { + return true; + } + + if (GEngine == nullptr || GEngine->GameViewport == nullptr) + { + return true; + } + + FSceneViewport* Viewport = GEngine->GameViewport->GetGameViewport(); + if (Viewport == nullptr) + { + return true; + } + + const FIntPoint Current = Viewport->GetSizeXY(); + if (Current.X == Width && Current.Y == Height) + { + return true; + } + + Viewport->ResizeFrame((uint32)Width, (uint32)Height, EWindowMode::Fullscreen); + + UE_LOG(LogTemp, Log, TEXT("[FlutterView_IOS] Render target was %dx%d, resized to %dx%d"), + Current.X, Current.Y, Width, Height); + + return true; +} + +/// Record the size the host wants. Applied later, on the game thread. +static void RequestPixelSize(int32 PixelWidth, int32 PixelHeight) +{ + if (PixelWidth <= 0 || PixelHeight <= 0) + { + return; + } + + GDesiredPixelWidth.store(PixelWidth, std::memory_order_release); + GDesiredPixelHeight.store(PixelHeight, std::memory_order_release); + + if (!GResolutionTicker.IsValid()) + { + GResolutionTicker = FTSTicker::GetCoreTicker().AddTicker( + FTickerDelegate::CreateStatic(&ApplyPendingResolution), 0.0f); + } +} + +/// The engine works in pixels while the host talks in points, so the scale /// The engine works in pixels while the host talks in points, so the scale /// factor has to be applied here or the engine renders at the wrong resolution /// on every device with a retina display, which is all of them. @@ -69,11 +153,19 @@ static void ApplyViewSize(FIOSView* View, float Width, float Height, float Scale View.ViewSize = CGSizeMake((CGFloat)Width * EffectiveScale, (CGFloat)Height * EffectiveScale); - [View CalculateContentScaleFactor:(int)(Width * EffectiveScale) - ScreenHeight:(int)(Height * EffectiveScale)]; + const int32 PixelWidth = (int32)(Width * EffectiveScale); + const int32 PixelHeight = (int32)(Height * EffectiveScale); + + [View CalculateContentScaleFactor:PixelWidth ScreenHeight:PixelHeight]; + [View UpdateRenderWidth:(unsigned int)PixelWidth andHeight:(unsigned int)PixelHeight]; - [View UpdateRenderWidth:(unsigned int)(Width * EffectiveScale) - andHeight:(unsigned int)(Height * EffectiveScale)]; + // Sizing the view is not enough. The engine keeps its own idea of the + // resolution, and in an embedded build nothing tells it ours, so it stays + // on the default 1280x720. That is landscape, and rendering it into a + // portrait view is what crops the scene into a band across the middle. + // Recorded, not applied. Changing the resolution has to happen on the game + // thread, and this runs on the main one. + RequestPixelSize(PixelWidth, PixelHeight); } extern "C" { diff --git a/packages/gameframework/ios/Classes/Core/GameEngineController.swift b/packages/gameframework/ios/Classes/Core/GameEngineController.swift index ca1ec00..5553331 100644 --- a/packages/gameframework/ios/Classes/Core/GameEngineController.swift +++ b/packages/gameframework/ios/Classes/Core/GameEngineController.swift @@ -196,6 +196,12 @@ open class GameEngineController: NSObject, GameEnginePlatformView, FlutterStream // MARK: - Method Channel Handler private func handleMethodCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + // Every call from Dart, named. This is the boundary that tells you + // whether a control that "does nothing" never left Dart or was dropped + // on this side, and there is no other way to see it: Dart's own stdout + // does not reach the device console. + NSLog("GameEngineController: <- \(call.method)") + switch call.method { case "engine#create": createEngine() From 9719f3fe50e02c0139354020b46e467a0c0bf90d Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 5 Sep 2026 20:49:02 -0500 Subject: [PATCH 11/25] feat(example): build the Unreal demo scene in code A scaffolded project has no level of its own and boots an engine map, which is empty, so the demo rendered a correctly running engine showing nothing and there was no way to tell that apart from a broken one. AFlutterDemoScene spawns the lot at BeginPlay: sun and fill light, a floor, the hero cube, five orbiting shapes, and a camera. Everything it uses ships with the engine and is already cooked, so there is no asset to make first and no reason to open the editor. Four things in here are less obvious than they look. The shapes came out grey because their own material is not cooked into this build, so they fall back to WorldGridMaterial and DefaultMaterial, neither of which exposes a colour. Setting a parameter a material does not have fails silently and looks exactly like a scene of identical checkerboards, so the scene asks candidates what they expose and uses one that answers. ACameraActor's constructor pins the camera to 16:9 and turns the aspect constraint on, and the engine then adds black bars. On a phone held upright that is most of the screen. With the constraint off, which axis holds the FOV decides what a tall screen shows, and the component's setting is ignored unless the override is on, so both are set. AFlutterBridge::GetInstance only ever looks for an actor. With no level there is nothing to have placed it in, so the game mode spawns one before anything asks, or every message from Flutter is dropped in silence. The on-screen sticks are removed at runtime. Setting DefaultTouchInterface in the ini would need a re-cook, and they only steal touches from the orbit. Drag anywhere to look around, pinch to zoom. Both track deltas between frames rather than absolutes, so a drag does not snap the camera to wherever your finger landed, and pinch is checked first because during one the first finger is moving too. The example's AppDelegate now subclasses IOSAppDelegate through the header the pod ships, which is what the engine requires of an embedding app. --- example/ios/Podfile | 2 +- example/ios/Runner.xcodeproj/project.pbxproj | 6 +- example/ios/Runner/AppDelegate.swift | 22 +- example/ios/Runner/Runner-Bridging-Header.h | 4 + example/unreal/demo/Config/DefaultInput.ini | 5 + .../FlutterPlugin/Private/FlutterBridge.cpp | 22 + .../Private/FlutterBridge_Apple.cpp | 16 + .../Private/IOS/FlutterView_IOS.mm | 100 ++- .../GameFrameworkProject/FlutterDemoScene.cpp | 621 ++++++++++++++++++ .../GameFrameworkProject/FlutterDemoScene.h | 100 +++ .../GameFrameworkProject/FlutterGameMode.cpp | 35 +- .../GameFrameworkProject/FlutterGameMode.h | 6 + .../GameFrameworkProject/RotatingCube.cpp | 6 + 13 files changed, 932 insertions(+), 13 deletions(-) create mode 100644 example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.cpp create mode 100644 example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.h diff --git a/example/ios/Podfile b/example/ios/Podfile index 8038819..b320f7b 100644 --- a/example/ios/Podfile +++ b/example/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -# platform :ios, '13.0' +platform :ios, '15.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index 5361801..23964ba 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -455,7 +455,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -590,7 +590,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -643,7 +643,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; STRING_CATALOG_GENERATE_SYMBOLS = YES; diff --git a/example/ios/Runner/AppDelegate.swift b/example/ios/Runner/AppDelegate.swift index 6266644..b2bc6df 100644 --- a/example/ios/Runner/AppDelegate.swift +++ b/example/ios/Runner/AppDelegate.swift @@ -1,13 +1,27 @@ import Flutter import UIKit +// Unreal will not start unless the app delegate descends from IOSAppDelegate. +// It says so as a Fatal. IOSAppDelegate is declared for us by +// UnrealAppDelegate.h, which Runner-Bridging-Header.h imports, so no engine +// headers are needed here. +// +// See engines/unreal/dart/ios/INTEGRATION.md. In particular: do not declare a +// 'window' property. Unreal's Window is filled in by UIKit calling setWindow:, +// and declaring your own takes that selector over. @main -@objc class AppDelegate: FlutterAppDelegate { +class AppDelegate: IOSAppDelegate { override func application( _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil ) -> Bool { - GeneratedPluginRegistrant.register(with: self) - return super.application(application, didFinishLaunchingWithOptions: launchOptions) + // Not calling super: in an embedded build that starts Unreal the + // non-embedded way and fights with the pod, which starts it for us. + if let controller = unrealWindow?.rootViewController as? FlutterViewController { + GeneratedPluginRegistrant.register(with: controller) + } else { + NSLog("AppDelegate: no FlutterViewController to register plugins with") + } + return true } } diff --git a/example/ios/Runner/Runner-Bridging-Header.h b/example/ios/Runner/Runner-Bridging-Header.h index 308a2a5..8f655f1 100644 --- a/example/ios/Runner/Runner-Bridging-Header.h +++ b/example/ios/Runner/Runner-Bridging-Header.h @@ -1 +1,5 @@ #import "GeneratedPluginRegistrant.h" + +// Declares Unreal's IOSAppDelegate so AppDelegate.swift can subclass it, which +// the engine requires of any app embedding it. +#import diff --git a/example/unreal/demo/Config/DefaultInput.ini b/example/unreal/demo/Config/DefaultInput.ini index 4124478..b6c149d 100644 --- a/example/unreal/demo/Config/DefaultInput.ini +++ b/example/unreal/demo/Config/DefaultInput.ini @@ -1,3 +1,8 @@ [/Script/Engine.InputSettings] -AxisConfig=(AxisKeyName="Gamepad_LeftX",AxisProperties=(DeadZone=0.25,Sensitivity=1.f,Exponent=1.f,bInvert=False)) +AxisConfig=(AxisKeyName="Gamepad_LeftX",AxisProperties=(DeadZone=0.25,Sensitivity=1.f,Exponent=1.f,bInvert=False)) + +; No on-screen joysticks. The engine adds a default touch interface on mobile, +; which is right for a game you drive by hand and wrong for a view embedded in a +; Flutter app, where the controls are Flutter widgets sitting on top of it. +DefaultTouchInterface=None diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge.cpp b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge.cpp index c2b7077..563e370 100644 --- a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge.cpp +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge.cpp @@ -1,6 +1,7 @@ // Copyright Epic Games, Inc. All Rights Reserved. #include "FlutterBridge.h" +#include "FlutterMessageRouter.h" #include "Engine/World.h" #include "Engine/Engine.h" #include "Engine/GameViewportClient.h" @@ -159,6 +160,27 @@ void AFlutterBridge::ReceiveFromFlutter(const FString& Target, const FString& Me { UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] Received from Flutter: Target=%s, Method=%s"), *Target, *Method); + // Hand it to the router, which is the half that reaches C++ actors. + // + // Every AFlutterActor registers itself with the router by name and expects + // messages to arrive that way. Firing only the Blueprint event below means a + // project without Blueprints receives nothing at all: the message crosses + // the channel, reaches the bridge, and stops here, with every log along the + // way reporting success. + bool bRouted = false; + if (UFlutterMessageRouter* Router = UFlutterMessageRouter::Get(this)) + { + bRouted = Router->RouteMessage(Target, Method, Data); + } + + if (!bRouted) + { + UE_LOG(LogTemp, Warning, + TEXT("[FlutterBridge] Nothing is registered for %s, so %s went nowhere. " + "Check GetFlutterTargetName on the actor you meant to reach."), + *Target, *Method); + } + // Fire Blueprint event OnMessageFromFlutter(Target, Method, Data); } diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp index 3e66780..70a553b 100644 --- a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp @@ -8,6 +8,7 @@ #include "Async/Async.h" #include "Misc/EmbeddedCommunication.h" #include "Misc/CoreDelegates.h" +#include "Misc/ConfigCacheIni.h" #include @@ -341,6 +342,9 @@ void UnrealBridge_SendToUnreal(const char* Target, const char* Method, const cha TEXT("[FlutterBridge_Apple] Dropping message from Flutter, no bridge actor: Target=%s, Method=%s"), *TargetString, *MethodString); } + + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Apple] Routed %s.%s"), + *TargetString, *MethodString); }); } @@ -511,6 +515,18 @@ void UnrealBridge_Init(void) int32_t UnrealBridge_Tick(float DeltaSeconds) { + // GConfig is null until the engine has loaded its inis, and + // FEmbeddedCommunication::TickGameThread reads a setting through it without + // checking. A host that starts ticking as soon as the engine is asked to + // start gets there first and dereferences null, which it must, because the + // engine blocks during startup waiting to be handed a view and the tick is + // what offers one. + if (GConfig == nullptr) + { + return 0; + } + + // TickGameThread must run on the thread that owns the engine. In an // embedded build that is whichever thread the host drives it from, so this // deliberately does not marshal: doing so would tick from somewhere the diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/IOS/FlutterView_IOS.mm b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/IOS/FlutterView_IOS.mm index 998ead9..0b3ff67 100644 --- a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/IOS/FlutterView_IOS.mm +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/IOS/FlutterView_IOS.mm @@ -1,6 +1,13 @@ // Copyright Epic Games, Inc. All Rights Reserved. #include "FlutterBridge.h" +#include "UnrealEngine.h" +#include "Engine/Engine.h" +#include "Containers/Ticker.h" +#include "Engine/GameViewportClient.h" +#include "Slate/SceneViewport.h" + +#include #if PLATFORM_IOS @@ -52,6 +59,83 @@ /// Apply the size Unreal should render at. /// +/// The size the host wants, in pixels, and the size the engine was last told +/// about. +static std::atomic GDesiredPixelWidth{0}; +static std::atomic GDesiredPixelHeight{0}; +static int32 GAppliedPixelWidth = 0; +static int32 GAppliedPixelHeight = 0; +static FTSTicker::FDelegateHandle GResolutionTicker; + +/// Make the engine's render target match the view it renders into. +/// +/// The engine creates its viewport before the host's view exists, at a default +/// 1280x720, and nothing in an embedded build ever corrects it. It then renders +/// that 16:9 frame into a correctly sized portrait surface, which looks like the +/// scene has been cropped into a band rather than like a render target that is +/// the wrong shape. +/// +/// Resizing the scene viewport is what actually moves it. Asking for a +/// resolution change instead does not: the console manager refuses the r.SetRes +/// write on priority grounds and says so in the log, and calling it off the game +/// thread aborts the process inside the CVar change. +/// +/// Compares against the viewport's real size every tick rather than remembering +/// what it last asked for, so it corrects itself if the engine resizes back, and +/// a rotation puts itself right. In the steady state it is one comparison. +static bool ApplyPendingResolution(float) +{ + const int32 Width = GDesiredPixelWidth.load(std::memory_order_acquire); + const int32 Height = GDesiredPixelHeight.load(std::memory_order_acquire); + if (Width <= 0 || Height <= 0) + { + return true; + } + + if (GEngine == nullptr || GEngine->GameViewport == nullptr) + { + return true; + } + + FSceneViewport* Viewport = GEngine->GameViewport->GetGameViewport(); + if (Viewport == nullptr) + { + return true; + } + + const FIntPoint Current = Viewport->GetSizeXY(); + if (Current.X == Width && Current.Y == Height) + { + return true; + } + + Viewport->ResizeFrame((uint32)Width, (uint32)Height, EWindowMode::Fullscreen); + + UE_LOG(LogTemp, Log, TEXT("[FlutterView_IOS] Render target was %dx%d, resized to %dx%d"), + Current.X, Current.Y, Width, Height); + + return true; +} + +/// Record the size the host wants. Applied later, on the game thread. +static void RequestPixelSize(int32 PixelWidth, int32 PixelHeight) +{ + if (PixelWidth <= 0 || PixelHeight <= 0) + { + return; + } + + GDesiredPixelWidth.store(PixelWidth, std::memory_order_release); + GDesiredPixelHeight.store(PixelHeight, std::memory_order_release); + + if (!GResolutionTicker.IsValid()) + { + GResolutionTicker = FTSTicker::GetCoreTicker().AddTicker( + FTickerDelegate::CreateStatic(&ApplyPendingResolution), 0.0f); + } +} + +/// The engine works in pixels while the host talks in points, so the scale /// The engine works in pixels while the host talks in points, so the scale /// factor has to be applied here or the engine renders at the wrong resolution /// on every device with a retina display, which is all of them. @@ -69,11 +153,19 @@ static void ApplyViewSize(FIOSView* View, float Width, float Height, float Scale View.ViewSize = CGSizeMake((CGFloat)Width * EffectiveScale, (CGFloat)Height * EffectiveScale); - [View CalculateContentScaleFactor:(int)(Width * EffectiveScale) - ScreenHeight:(int)(Height * EffectiveScale)]; + const int32 PixelWidth = (int32)(Width * EffectiveScale); + const int32 PixelHeight = (int32)(Height * EffectiveScale); + + [View CalculateContentScaleFactor:PixelWidth ScreenHeight:PixelHeight]; + [View UpdateRenderWidth:(unsigned int)PixelWidth andHeight:(unsigned int)PixelHeight]; - [View UpdateRenderWidth:(unsigned int)(Width * EffectiveScale) - andHeight:(unsigned int)(Height * EffectiveScale)]; + // Sizing the view is not enough. The engine keeps its own idea of the + // resolution, and in an embedded build nothing tells it ours, so it stays + // on the default 1280x720. That is landscape, and rendering it into a + // portrait view is what crops the scene into a band across the middle. + // Recorded, not applied. Changing the resolution has to happen on the game + // thread, and this runs on the main one. + RequestPixelSize(PixelWidth, PixelHeight); } extern "C" { diff --git a/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.cpp b/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.cpp new file mode 100644 index 0000000..d70428a --- /dev/null +++ b/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.cpp @@ -0,0 +1,621 @@ +#include "FlutterDemoScene.h" + +#include "RotatingCube.h" +#include "FlutterBridge.h" + +#include "Camera/CameraActor.h" +#include "Camera/CameraComponent.h" +#include "Engine/GameViewportClient.h" +#include "Slate/SceneViewport.h" +#include "Components/DirectionalLightComponent.h" +#include "Components/SkyAtmosphereComponent.h" +#include "Components/SkyLightComponent.h" +#include "Components/ExponentialHeightFogComponent.h" +#include "Components/StaticMeshComponent.h" +#include "Engine/DirectionalLight.h" +#include "Engine/Engine.h" +#include "Engine/ExponentialHeightFog.h" +#include "Engine/SkyLight.h" +#include "Engine/StaticMesh.h" +#include "Engine/StaticMeshActor.h" +#include "Engine/GameViewportClient.h" +#include "Engine/World.h" +#include "GameFramework/PlayerController.h" +#include "Materials/Material.h" +#include "Materials/MaterialInstanceDynamic.h" +#include "Materials/MaterialInterface.h" + +namespace +{ + /// The ring of shapes around the cube. + constexpr float OrbitRadius = 320.0f; + constexpr float OrbitDegreesPerSecond = 18.0f; + constexpr float OrbitBobHeight = 35.0f; + + /// How far a drag across the screen swings the camera. + constexpr float DegreesPerPixelYaw = 0.32f; + constexpr float DegreesPerPixelPitch = 0.22f; + + /// Until someone touches the screen, drift slowly so the scene reads as + /// alive rather than as a still frame. + constexpr float IdleDriftDegreesPerSecond = 4.0f; + + /// Keep the camera out of the floor and off the top of the sky. + constexpr float MinPitch = -70.0f; + constexpr float MaxPitch = 12.0f; + + /// How close and how far a pinch can take the camera. + constexpr float MinDistance = 260.0f; + constexpr float MaxDistance = 1800.0f; + + constexpr float FocusHeight = 60.0f; +} + +AFlutterDemoScene::AFlutterDemoScene() +{ + PrimaryActorTick.bCanEverTick = true; + RootComponent = CreateDefaultSubobject(TEXT("Root")); +} + +UMaterialInterface* AFlutterDemoScene::FindTintableMaterial() +{ + static UMaterialInterface* Cached = nullptr; + static bool bSearched = false; + if (bSearched) + { + return Cached; + } + bSearched = true; + + // The shape meshes do not arrive with a tintable material. Their own + // material is not cooked into this build, so they fall back to + // WorldGridMaterial and DefaultMaterial, neither of which exposes a colour, + // which is how you end up with a scene of identical grey checkerboards. + // + // So find one that does. Ask each candidate what it exposes rather than + // trusting a parameter name, because setting a name a material does not + // have fails silently and looks exactly like this bug. + TArray Candidates; + if (GEngine != nullptr) + { + // These are TObjectPtr, so unwrap before the upcast. + Candidates.Add(GEngine->LevelColorationLitMaterial.Get()); + Candidates.Add(GEngine->VertexColorMaterial.Get()); + Candidates.Add(GEngine->DebugMeshMaterial.Get()); + } + Candidates.Add(LoadObject( + nullptr, TEXT("/Engine/BasicShapes/BasicShapeMaterial"))); + + for (UMaterialInterface* Candidate : Candidates) + { + if (Candidate == nullptr) + { + continue; + } + + TArray Infos; + TArray Guids; + Candidate->GetAllVectorParameterInfo(Infos, Guids); + + FString Names; + for (const FMaterialParameterInfo& Info : Infos) + { + Names += Info.Name.ToString() + TEXT(" "); + } + UE_LOG(LogTemp, Log, TEXT("[FlutterDemoScene] %s vector params: [%s]"), + *GetNameSafe(Candidate), *Names); + + if (Infos.Num() > 0) + { + Cached = Candidate; + return Cached; + } + } + + UE_LOG(LogTemp, Warning, + TEXT("[FlutterDemoScene] No tintable material in this build, so the shapes stay grey")); + return nullptr; +} + +void AFlutterDemoScene::TintMesh(UStaticMeshComponent* MeshComponent, const FLinearColor& Color) +{ + if (MeshComponent == nullptr) + { + return; + } + + UMaterialInterface* Parent = FindTintableMaterial(); + if (Parent == nullptr) + { + return; + } + + UMaterialInstanceDynamic* Material = UMaterialInstanceDynamic::Create(Parent, this); + if (Material == nullptr) + { + return; + } + + TArray Infos; + TArray Guids; + Material->GetAllVectorParameterInfo(Infos, Guids); + if (Infos.Num() == 0) + { + return; + } + + // Prefer an obviously colour-shaped name, otherwise take the first one. + static const FName Preferred[] = { + FName(TEXT("Color")), FName(TEXT("Colour")), + FName(TEXT("BaseColor")), FName(TEXT("Base Color")), FName(TEXT("Tint")) + }; + + FMaterialParameterInfo Chosen = Infos[0]; + for (const FName& Name : Preferred) + { + const FMaterialParameterInfo* Match = Infos.FindByPredicate( + [&Name](const FMaterialParameterInfo& Info) { return Info.Name == Name; }); + if (Match != nullptr) + { + Chosen = *Match; + break; + } + } + + Material->SetVectorParameterValue(Chosen.Name, Color); + MeshComponent->SetMaterial(0, Material); +} + +AStaticMeshActor* AFlutterDemoScene::SpawnShape(const TCHAR* MeshPath, + const FVector& Location, const FVector& Scale, const FLinearColor& Color) +{ + UWorld* World = GetWorld(); + if (World == nullptr) + { + return nullptr; + } + + UStaticMesh* Mesh = LoadObject(nullptr, MeshPath); + if (Mesh == nullptr) + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterDemoScene] %s is not cooked into this build"), MeshPath); + return nullptr; + } + + FActorSpawnParameters Params; + Params.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn; + AStaticMeshActor* Actor = World->SpawnActor( + AStaticMeshActor::StaticClass(), FTransform(Location), Params); + if (Actor == nullptr) + { + return nullptr; + } + + UStaticMeshComponent* MeshComponent = Actor->GetStaticMeshComponent(); + + // Anything spawned at runtime has to be Movable, the floor included. A + // Static actor expects baked lighting, and a level built in code has none, + // so it would render unlit. + MeshComponent->SetMobility(EComponentMobility::Movable); + MeshComponent->SetStaticMesh(Mesh); + Actor->SetActorScale3D(Scale); + TintMesh(MeshComponent, Color); + + return Actor; +} + +void AFlutterDemoScene::BuildSky() +{ + UWorld* World = GetWorld(); + FActorSpawnParameters Params; + Params.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn; + + // A computed sky rather than a cubemap. SkyAtmosphere scatters light from + // the sun direction, so there is no texture to author and nothing extra to + // cook, and turning the sun changes the sky with it. + World->SpawnActor(ASkyAtmosphere::StaticClass(), FTransform::Identity, Params); + + // Let the sky light the scene. Real-time capture means it picks up the + // atmosphere above instead of needing a cubemap of its own. + if (ASkyLight* Sky = World->SpawnActor( + ASkyLight::StaticClass(), FTransform(FVector(0.0f, 0.0f, 200.0f)), Params)) + { + if (USkyLightComponent* Component = Sky->GetLightComponent()) + { + Component->SetMobility(EComponentMobility::Movable); + Component->SetRealTimeCaptureEnabled(true); + Component->SetIntensity(1.0f); + } + } + + // A little haze, so the floor fades out instead of ending on a hard line. + if (AExponentialHeightFog* Fog = World->SpawnActor( + AExponentialHeightFog::StaticClass(), + FTransform(FVector(0.0f, 0.0f, -200.0f)), Params)) + { + if (UExponentialHeightFogComponent* Component = Fog->GetComponent()) + { + Component->SetFogDensity(0.015f); + Component->SetFogInscatteringColor(FLinearColor(0.42f, 0.55f, 0.78f)); + Component->SetStartDistance(600.0f); + } + } +} + +void AFlutterDemoScene::BuildLights() +{ + UWorld* World = GetWorld(); + FActorSpawnParameters Params; + Params.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn; + + // The sun. Low and warm, which gives the shapes long shadows and stops the + // scene reading as a flat product render. + SunLight = World->SpawnActor( + ADirectionalLight::StaticClass(), + FTransform(FRotator(-32.0f, -40.0f, 0.0f)), Params); + if (SunLight != nullptr) + { + SunLight->SetMobility(EComponentMobility::Movable); + if (UDirectionalLightComponent* Light = Cast(SunLight->GetLightComponent())) + { + Light->SetIntensity(5.0f); + Light->SetLightColor(FLinearColor(1.0f, 0.93f, 0.82f)); + Light->SetCastShadows(true); + + // This is what makes SkyAtmosphere treat it as the sun, so the sky + // brightens around the direction the light points from. + Light->SetAtmosphereSunLight(true); + } + } + + // Cool fill from behind, so the shadowed faces read as shape rather than + // as black. + if (ADirectionalLight* Fill = World->SpawnActor( + ADirectionalLight::StaticClass(), + FTransform(FRotator(-18.0f, 145.0f, 0.0f)), Params)) + { + Fill->SetMobility(EComponentMobility::Movable); + if (UDirectionalLightComponent* Light = Cast(Fill->GetLightComponent())) + { + Light->SetIntensity(1.2f); + Light->SetLightColor(FLinearColor(0.42f, 0.56f, 1.0f)); + Light->SetCastShadows(false); + } + } +} + +void AFlutterDemoScene::BuildShapes() +{ + UWorld* World = GetWorld(); + FActorSpawnParameters Params; + Params.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn; + + // Floor, wide enough to catch the shadows and to run out into the fog. + SpawnShape(TEXT("/Engine/BasicShapes/Plane"), FVector(0.0f, 0.0f, -110.0f), + FVector(40.0f, 40.0f, 1.0f), FLinearColor(0.10f, 0.12f, 0.16f)); + + // The hero cube. This is the actor Flutter sends setSpeed, setAxis and + // setColor to, so it is spawned rather than dropped in a level. + HeroCube = World->SpawnActor(ARotatingCube::StaticClass(), + FTransform(FVector(0.0f, 0.0f, 40.0f)), Params); + if (HeroCube != nullptr) + { + HeroCube->SetActorScale3D(FVector(1.6f)); + } + + struct FOrbiter + { + const TCHAR* Mesh; + FLinearColor Color; + float Scale; + }; + static const FOrbiter Ring[] = { + {TEXT("/Engine/BasicShapes/Sphere"), FLinearColor(0.92f, 0.28f, 0.32f), 0.7f}, + {TEXT("/Engine/BasicShapes/Cone"), FLinearColor(0.98f, 0.72f, 0.20f), 0.8f}, + {TEXT("/Engine/BasicShapes/Cylinder"), FLinearColor(0.24f, 0.82f, 0.60f), 0.7f}, + {TEXT("/Engine/BasicShapes/Cube"), FLinearColor(0.34f, 0.52f, 0.96f), 0.7f}, + {TEXT("/Engine/BasicShapes/Sphere"), FLinearColor(0.72f, 0.38f, 0.95f), 0.6f}, + }; + + const int32 Count = UE_ARRAY_COUNT(Ring); + for (int32 Index = 0; Index < Count; ++Index) + { + const float Angle = (360.0f / Count) * Index; + const FVector Location( + OrbitRadius * FMath::Cos(FMath::DegreesToRadians(Angle)), + OrbitRadius * FMath::Sin(FMath::DegreesToRadians(Angle)), + 0.0f); + + if (AStaticMeshActor* Shape = SpawnShape(Ring[Index].Mesh, Location, + FVector(Ring[Index].Scale), Ring[Index].Color)) + { + Orbiters.Add(Shape); + } + } +} + +void AFlutterDemoScene::BuildCamera() +{ + FActorSpawnParameters Params; + Params.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn; + + Camera = GetWorld()->SpawnActor( + ACameraActor::StaticClass(), FTransform::Identity, Params); + + if (Camera != nullptr) + { + // ACameraActor's constructor pins the camera to 16:9 and turns on the + // aspect ratio constraint, and the engine then adds black bars to fill + // whatever it is rendering into. On a phone held upright that is most + // of the screen, and it looks exactly like the scene has been cropped + // into a landscape band, which sends you hunting through resolutions + // and viewports instead. Let it fill the view it is given. + UCameraComponent* Component = Camera->GetCameraComponent(); + Component->SetConstraintAspectRatio(false); + + // With the constraint off, which axis the FOV is held on decides what a + // tall screen shows. The component's own setting is ignored unless this + // override is on, and the player's default holds the vertical FOV, + // which squeezes the horizontal one on a portrait screen and looks like + // a zoomed-in landscape crop. Hold the horizontal FOV instead, so a + // taller screen shows more of the scene rather than less. + Component->bOverrideAspectRatioAxisConstraint = true; + Component->SetAspectRatioAxisConstraint(EAspectRatioAxisConstraint::AspectRatio_MaintainXFOV); + } + + PositionCamera(); +} + +void AFlutterDemoScene::PositionCamera() +{ + if (Camera == nullptr) + { + return; + } + + // A phone held upright has a tall, narrow viewport, and a framing chosen + // for a wide one puts the ring of shapes off both edges. So widen the lens + // and back off as the viewport gets narrower, which keeps the same subject + // in frame in either orientation. + float Aspect = 1.0f; + if (GEngine != nullptr && GEngine->GameViewport != nullptr) + { + FVector2D Size; + GEngine->GameViewport->GetViewportSize(Size); + if (Size.X > 0.0f && Size.Y > 0.0f) + { + Aspect = Size.X / Size.Y; + } + } + + const bool bPortrait = Aspect < 1.0f; + const float FieldOfView = bPortrait ? 88.0f : 70.0f; + const float Distance = OrbitDistance * (bPortrait ? 1.28f : 1.0f); + + const float YawRadians = FMath::DegreesToRadians(OrbitYaw); + const float PitchRadians = FMath::DegreesToRadians(OrbitPitch); + + const float Horizontal = Distance * FMath::Cos(PitchRadians); + const FVector Focus(0.0f, 0.0f, FocusHeight); + const FVector Location = Focus + FVector( + -Horizontal * FMath::Cos(YawRadians), + -Horizontal * FMath::Sin(YawRadians), + -Distance * FMath::Sin(PitchRadians)); + + Camera->SetActorLocation(Location); + Camera->SetActorRotation((Focus - Location).Rotation()); + Camera->GetCameraComponent()->SetFieldOfView(FieldOfView); +} + +void AFlutterDemoScene::BeginPlay() +{ + Super::BeginPlay(); + + if (GetWorld() == nullptr) + { + return; + } + + BuildSky(); + BuildLights(); + BuildShapes(); + BuildCamera(); + TakeOverTheView(); + + UE_LOG(LogTemp, Log, TEXT("[FlutterDemoScene] Scene built: %d orbiters, cube %s"), + Orbiters.Num(), HeroCube ? TEXT("yes") : TEXT("no")); +} + +void AFlutterDemoScene::TakeOverTheView() +{ + if (bViewClaimed || Camera == nullptr) + { + return; + } + + APlayerController* Controller = GetWorld()->GetFirstPlayerController(); + if (Controller == nullptr) + { + return; + } + + Controller->SetViewTarget(Camera); + + // Touch has to be switched on explicitly, and the engine's own on-screen + // stick is off, so the whole surface is free for dragging. + Controller->bShowMouseCursor = false; + Controller->bEnableTouchEvents = true; + Controller->bEnableTouchOverEvents = true; + + // Remove the engine's on-screen sticks. Setting DefaultTouchInterface in + // the ini would need a re-cook, and this is the same thing at runtime. + // They are right for a game you drive by hand and wrong for a view sitting + // under Flutter controls, where they only steal touches from the orbit. + Controller->ActivateTouchInterface(nullptr); + + bViewClaimed = true; + UE_LOG(LogTemp, Log, TEXT("[FlutterDemoScene] Camera is the view target")); +} + +void AFlutterDemoScene::UpdateOrbitFromTouch(float DeltaSeconds) +{ + APlayerController* Controller = GetWorld()->GetFirstPlayerController(); + if (Controller == nullptr) + { + return; + } + + float X1 = 0.0f, Y1 = 0.0f, X2 = 0.0f, Y2 = 0.0f; + bool bFirst = false, bSecond = false; + Controller->GetInputTouchState(ETouchIndex::Touch1, X1, Y1, bFirst); + Controller->GetInputTouchState(ETouchIndex::Touch2, X2, Y2, bSecond); + + // Two fingers pinch to zoom. Checked first, because during a pinch the + // first finger is also moving and would otherwise swing the camera at the + // same time. + if (bFirst && bSecond) + { + const float Spread = FVector2D::Distance(FVector2D(X1, Y1), FVector2D(X2, Y2)); + + if (bWasPinching && LastPinchSpread > KINDA_SMALL_NUMBER) + { + // Move the camera by the ratio the fingers moved, so the zoom feels + // the same whether they start close together or far apart. + OrbitDistance = FMath::Clamp( + OrbitDistance * (LastPinchSpread / Spread), MinDistance, MaxDistance); + PositionCamera(); + } + + LastPinchSpread = Spread; + bWasPinching = true; + bWasTouching = false; + bViewerHasTakenOver = true; + return; + } + + bWasPinching = false; + + if (bFirst) + { + const FVector2D Touch(X1, Y1); + + // Only orbit on the second and later frames of a drag. Using the first + // one would treat wherever the finger landed as a delta and snap the + // camera across the scene. + if (bWasTouching) + { + const FVector2D Delta = Touch - LastTouch; + OrbitYaw += Delta.X * DegreesPerPixelYaw; + OrbitPitch = FMath::Clamp( + OrbitPitch + Delta.Y * DegreesPerPixelPitch, MinPitch, MaxPitch); + PositionCamera(); + } + + LastTouch = Touch; + bWasTouching = true; + bViewerHasTakenOver = true; + return; + } + + bWasTouching = false; + + // Drift until someone takes over, then leave the camera where they put it. + if (!bViewerHasTakenOver) + { + OrbitYaw += IdleDriftDegreesPerSecond * DeltaSeconds; + PositionCamera(); + } +} + +void AFlutterDemoScene::ReportRenderState() const +{ + FString CameraState = TEXT("no camera"); + if (Camera != nullptr) + { + if (const UCameraComponent* Component = Camera->GetCameraComponent()) + { + CameraState = FString::Printf(TEXT("constrain=%d fov=%.1f axisOverride=%d axis=%d"), + Component->bConstrainAspectRatio ? 1 : 0, + Component->FieldOfView, + Component->bOverrideAspectRatioAxisConstraint ? 1 : 0, + (int32)Component->AspectRatioAxisConstraint.GetValue()); + } + } + + FString ViewportState = TEXT("no game viewport"); + if (GEngine != nullptr && GEngine->GameViewport != nullptr) + { + FVector2D Size = FVector2D::ZeroVector; + GEngine->GameViewport->GetViewportSize(Size); + ViewportState = FString::Printf(TEXT("client=%.0fx%.0f"), Size.X, Size.Y); + + if (const FSceneViewport* Scene = GEngine->GameViewport->GetGameViewport()) + { + const FIntPoint SceneSize = Scene->GetSizeXY(); + ViewportState += FString::Printf(TEXT(" scene=%dx%d"), SceneSize.X, SceneSize.Y); + } + else + { + ViewportState += TEXT(" scene=none"); + } + } + + UE_LOG(LogTemp, Log, TEXT("[FlutterDemoScene] render: camera[%s] viewport[%s]"), + *CameraState, *ViewportState); + + // Also send it to Flutter. The engine's log file is buffered and a reinstall + // wipes the container, so the HUD panel is the one readout that is reliably + // there while the thing is actually running. + if (AFlutterBridge* Bridge = AFlutterBridge::GetInstance(this)) + { + Bridge->SendToFlutter(TEXT("Scene"), TEXT("renderState"), + FString::Printf(TEXT("%s | %s"), *CameraState, *ViewportState)); + } +} + +void AFlutterDemoScene::Tick(float DeltaSeconds) +{ + Super::Tick(DeltaSeconds); + + // The player controller may not exist yet when the scene is built, so keep + // asking until it does rather than assuming the order. + if (!bViewClaimed) + { + TakeOverTheView(); + return; + } + + UpdateOrbitFromTouch(DeltaSeconds); + + SceneTime += DeltaSeconds; + + // Report what the renderer is actually doing, once a second. The view and + // the Metal surface measure correctly from the host side, so anything that + // still letterboxes has to be visible from in here. + ReportSeconds += DeltaSeconds; + if (ReportSeconds >= 3.0f) + { + ReportSeconds = 0.0f; + ReportRenderState(); + } + + const int32 Count = Orbiters.Num(); + for (int32 Index = 0; Index < Count; ++Index) + { + AStaticMeshActor* Shape = Orbiters[Index]; + if (Shape == nullptr) + { + continue; + } + + const float Angle = (360.0f / Count) * Index + SceneTime * OrbitDegreesPerSecond; + const float Radians = FMath::DegreesToRadians(Angle); + const float Bob = OrbitBobHeight * FMath::Sin(SceneTime * 1.3f + Index); + + Shape->SetActorLocation(FVector( + OrbitRadius * FMath::Cos(Radians), + OrbitRadius * FMath::Sin(Radians), + Bob)); + Shape->AddActorLocalRotation(FRotator(0.0f, 40.0f * DeltaSeconds, 0.0f)); + } +} diff --git a/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.h b/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.h new file mode 100644 index 0000000..1248115 --- /dev/null +++ b/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.h @@ -0,0 +1,100 @@ +#pragma once + +#include "CoreMinimal.h" +#include "GameFramework/Actor.h" +#include "FlutterDemoScene.generated.h" + +class ACameraActor; +class ADirectionalLight; +class ARotatingCube; +class AStaticMeshActor; + +/** + * Builds the demo scene in code, at runtime. + * + * There is no .umap here on purpose. A scaffolded project has no level of its + * own and boots an engine map, which is empty, so anything you want to see has + * to be spawned. That also keeps the demo working without opening the editor, + * which matters when the whole point is to run it from Flutter. + * + * Nothing here needs an asset you have to make first. The shapes come from + * /Engine/BasicShapes, and the sky is SkyAtmosphere, which is computed rather + * than textured, so there is no cubemap to cook. + */ +UCLASS() +class AFlutterDemoScene : public AActor +{ + GENERATED_BODY() + +public: + AFlutterDemoScene(); + + virtual void BeginPlay() override; + virtual void Tick(float DeltaSeconds) override; + + /** The hero cube, which is what Flutter talks to. */ + UPROPERTY(Transient) + ARotatingCube* HeroCube = nullptr; + +private: + /** Spawn one tinted shape. Always Movable: see the note in the .cpp. */ + AStaticMeshActor* SpawnShape(const TCHAR* MeshPath, const FVector& Location, + const FVector& Scale, const FLinearColor& Color); + + /** A material that actually exposes a colour, or null if this build has none. */ + static class UMaterialInterface* FindTintableMaterial(); + + /** Tint a mesh, whatever its material happens to call the parameter. */ + void TintMesh(class UStaticMeshComponent* MeshComponent, const FLinearColor& Color); + + void BuildSky(); + void BuildLights(); + void BuildShapes(); + void BuildCamera(); + + /** Point the player at our camera. */ + void TakeOverTheView(); + + /** Read touch and turn a drag into a camera orbit. */ + void UpdateOrbitFromTouch(float DeltaSeconds); + + /** Place the camera from the current orbit angles and viewport shape. */ + void PositionCamera(); + + /** Log what the renderer is doing, so a letterbox can be traced to a cause. */ + void ReportRenderState() const; + + UPROPERTY(Transient) + ACameraActor* Camera = nullptr; + + UPROPERTY(Transient) + ADirectionalLight* SunLight = nullptr; + + UPROPERTY(Transient) + TArray Orbiters; + + /** Seconds since BeginPlay, which drives the orbit of the shapes. */ + float SceneTime = 0.0f; + + /** Throttles the render state report to once a second. */ + float ReportSeconds = 0.0f; + + /** Where the camera sits, in orbit terms. Yaw and pitch are degrees. */ + float OrbitYaw = 0.0f; + float OrbitPitch = -14.0f; + float OrbitDistance = 640.0f; + + /** Touch tracking, so a drag becomes a delta rather than a jump. */ + bool bWasTouching = false; + FVector2D LastTouch = FVector2D::ZeroVector; + + /** Pinch tracking. Same reason: a ratio between frames, not an absolute. */ + bool bWasPinching = false; + float LastPinchSpread = 0.0f; + + /** Idle drift, which stops the first time the viewer touches the screen. */ + bool bViewerHasTakenOver = false; + + /** The view target has to be claimed after the player controller exists. */ + bool bViewClaimed = false; +}; diff --git a/example/unreal/demo/Source/GameFrameworkProject/FlutterGameMode.cpp b/example/unreal/demo/Source/GameFrameworkProject/FlutterGameMode.cpp index f80f533..345b730 100644 --- a/example/unreal/demo/Source/GameFrameworkProject/FlutterGameMode.cpp +++ b/example/unreal/demo/Source/GameFrameworkProject/FlutterGameMode.cpp @@ -3,6 +3,7 @@ #include "FlutterGameMode.h" #include "FlutterBridge.h" #include "FlutterMessageRouter.h" +#include "FlutterDemoScene.h" #include "Engine/World.h" #include "TimerManager.h" #include "Dom/JsonObject.h" @@ -16,7 +17,10 @@ AFlutterGameMode::AFlutterGameMode() CurrentScore = 0; CurrentLevel = 1; FlutterTargetName = TEXT("GameMode"); - bAutoSyncState = true; + // Off by default. A heartbeat once a second is useful when you are + // debugging the channel and pure noise once it works, and it buries the + // messages you actually want to read in any log panel on the Flutter side. + bAutoSyncState = false; StateSyncInterval = 1.0f; FlutterBridge = nullptr; MessageRouter = nullptr; @@ -25,7 +29,36 @@ AFlutterGameMode::AFlutterGameMode() void AFlutterGameMode::BeginPlay() { Super::BeginPlay(); + + // The bridge is an actor, and AFlutterBridge::GetInstance only ever looks + // for one. With no level of our own there is nothing to have placed it in, + // so spawn it here, before anything asks for it. Without this the engine + // runs, the scene renders, and every message from Flutter is quietly + // dropped. + if (UWorld* World = GetWorld()) + { + if (AFlutterBridge::GetInstance(this) == nullptr) + { + FActorSpawnParameters BridgeParams; + BridgeParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn; + World->SpawnActor( + AFlutterBridge::StaticClass(), FTransform::Identity, BridgeParams); + UE_LOG(LogTemp, Log, TEXT("[FlutterGameMode] Spawned the Flutter bridge actor")); + } + } + InitializeFlutter(); + + // Build the scene in code. A scaffolded project has no level of its own and + // boots an engine map, which is empty, so without this you get a correctly + // running engine rendering nothing and no way to tell the two apart. + if (UWorld* World = GetWorld()) + { + FActorSpawnParameters Params; + Params.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn; + DemoScene = World->SpawnActor( + AFlutterDemoScene::StaticClass(), FTransform::Identity, Params); + } } void AFlutterGameMode::EndPlay(const EEndPlayReason::Type EndPlayReason) diff --git a/example/unreal/demo/Source/GameFrameworkProject/FlutterGameMode.h b/example/unreal/demo/Source/GameFrameworkProject/FlutterGameMode.h index de064ff..5fc1431 100644 --- a/example/unreal/demo/Source/GameFrameworkProject/FlutterGameMode.h +++ b/example/unreal/demo/Source/GameFrameworkProject/FlutterGameMode.h @@ -10,6 +10,8 @@ class AFlutterBridge; class UFlutterMessageRouter; +class AFlutterDemoScene; + /** * Flutter Game Mode - Base GameMode with Flutter integration * @@ -253,6 +255,10 @@ class AFlutterGameMode : public AGameModeBase UPROPERTY() UFlutterMessageRouter* MessageRouter; + /** The demo scene, spawned in BeginPlay because there is no level to hold it. */ + UPROPERTY() + AFlutterDemoScene* DemoScene = nullptr; + // State sync timer FTimerHandle StateSyncTimerHandle; diff --git a/example/unreal/demo/Source/GameFrameworkProject/RotatingCube.cpp b/example/unreal/demo/Source/GameFrameworkProject/RotatingCube.cpp index 53bf01c..5c89a5d 100644 --- a/example/unreal/demo/Source/GameFrameworkProject/RotatingCube.cpp +++ b/example/unreal/demo/Source/GameFrameworkProject/RotatingCube.cpp @@ -89,6 +89,12 @@ void ARotatingCube::HandleFlutterMessage_Implementation(const FString& Method, c { UE_LOG(LogTemp, Log, TEXT("[RotatingCube] Message: %s(%s)"), *Method, *Data); + // Acknowledge every message straight back to Flutter. The engine's log file + // is buffered and only reliably shows startup, so an ack in the HUD is the + // one signal that says whether a control reached the actor at all, as + // opposed to being dropped somewhere along the route. + SendToFlutter(TEXT("gotMessage"), Method); + if (Method == TEXT("setSpeed")) { float NewSpeed = FCString::Atof(*Data); From 3f134d7090be2b36412e83ba53fb682796b46902 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 5 Sep 2026 21:27:39 -0500 Subject: [PATCH 12/25] fix(unreal): route wildcard handlers, which is how every actor registers Second half of the inbound path. With ReceiveFromFlutter finally calling the router, messages reached it and died one step later: FString CacheKey = GetCacheKey(Target, Method); // "Demo.setColor" if (TryRouteCached(CacheKey, Method, Data)) { ... } Meanwhile every actor registers like this: Router->RegisterMethod(TargetName, TEXT("*"), MessageDelegate); So the handler lives under "Demo.*" and the router only ever looked for the exact method name. The wildcard was written on one side and never implemented on the other, which made it a handler nothing could reach. Route now falls back to the wildcard key after the exact one misses, for text and binary alike. The actor still receives the real method name, so it can dispatch on it as before. Also traces the two ends of the game thread queue back to Flutter. The engine's log file is buffered and mostly shows startup, so a message that disappears between the bridge and an actor leaves nothing to read, and this took far longer to find than it should have for exactly that reason. The trace goes through the message callback rather than the bridge actor, so it still arrives when the thing being diagnosed is the bridge actor itself. Confirmed on device: setSpeed reaches the cube and it answers with onSpeedChanged carrying the new value. --- .../ios/Classes/UnrealEngineController.swift | 2 +- .../Private/FlutterBridge_Apple.cpp | 19 +++++++++++++++---- .../Private/FlutterMessageRouter.cpp | 17 +++++++++++++++++ .../Private/FlutterBridge_Apple.cpp | 19 +++++++++++++++---- .../Private/FlutterMessageRouter.cpp | 17 +++++++++++++++++ 5 files changed, 65 insertions(+), 9 deletions(-) diff --git a/engines/unreal/dart/ios/Classes/UnrealEngineController.swift b/engines/unreal/dart/ios/Classes/UnrealEngineController.swift index 9b6c71b..180333c 100644 --- a/engines/unreal/dart/ios/Classes/UnrealEngineController.swift +++ b/engines/unreal/dart/ios/Classes/UnrealEngineController.swift @@ -372,7 +372,7 @@ public class UnrealEngineController: GameEngineController { return } - NSLog("UnrealEngineController: Forwarding message to Flutter: \(target).\(method)") + NSLog("UnrealEngineController: Forwarding message to Flutter: \(target).\(method) \(data)") sendEvent(name: "onMessage", data: [ "target": target, "method": method, diff --git a/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp b/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp index 70a553b..24bceae 100644 --- a/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp +++ b/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp @@ -330,9 +330,23 @@ void UnrealBridge_SendToUnreal(const char* Target, const char* Method, const cha const FString MethodString = CStringToFString(Method); const FString DataString = CStringToFString(Data); + // Traced back to Flutter rather than logged. The engine's log file is + // buffered and mostly shows startup, so a message that vanishes between + // here and the actor leaves nothing to read. These go through the message + // callback directly, which needs no bridge actor, so they still arrive when + // the thing being diagnosed is the bridge actor itself. + SendToFlutter_Apple(TEXT("Trace"), TEXT("queued"), + FString::Printf(TEXT("%s.%s"), *TargetString, *MethodString)); + RunOnGameThread([TargetString, MethodString, DataString]() { - if (AFlutterBridge* Bridge = GFlutterBridgeInstance.load(std::memory_order_acquire)) + AFlutterBridge* Bridge = GFlutterBridgeInstance.load(std::memory_order_acquire); + + SendToFlutter_Apple(TEXT("Trace"), TEXT("drained"), + FString::Printf(TEXT("%s.%s bridge=%s"), *TargetString, *MethodString, + Bridge != nullptr ? TEXT("yes") : TEXT("null"))); + + if (Bridge != nullptr) { Bridge->ReceiveFromFlutter(TargetString, MethodString, DataString); } @@ -342,9 +356,6 @@ void UnrealBridge_SendToUnreal(const char* Target, const char* Method, const cha TEXT("[FlutterBridge_Apple] Dropping message from Flutter, no bridge actor: Target=%s, Method=%s"), *TargetString, *MethodString); } - - UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Apple] Routed %s.%s"), - *TargetString, *MethodString); }); } diff --git a/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterMessageRouter.cpp b/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterMessageRouter.cpp index 4b3745a..296c7bb 100644 --- a/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterMessageRouter.cpp +++ b/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterMessageRouter.cpp @@ -184,6 +184,16 @@ bool UFlutterMessageRouter::RouteMessage(const FString& Target, const FString& M return true; } + // Then the wildcard, which is what every AFlutterActor registers under. + // RegisterMethod(Target, "*", ...) is how an actor says "send me everything", + // and looking up only the exact method name means that handler can never be + // found. The actor still gets the real method name, so it can dispatch. + if (TryRouteCached(GetCacheKey(Target, TEXT("*")), Method, Data)) + { + Statistics.MessagesRouted++; + return true; + } + // Check if target is registered but method is not if (Targets.Contains(Target)) { @@ -215,6 +225,13 @@ bool UFlutterMessageRouter::RouteBinaryMessage(const FString& Target, const FStr return true; } + // Then the wildcard, for the same reason as the text path above. + if (TryRouteBinaryCached(GetCacheKey(Target, TEXT("*")), Method, Data)) + { + Statistics.MessagesRouted++; + return true; + } + // Check if target is registered but method is not if (Targets.Contains(Target)) { diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp index 70a553b..24bceae 100644 --- a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp @@ -330,9 +330,23 @@ void UnrealBridge_SendToUnreal(const char* Target, const char* Method, const cha const FString MethodString = CStringToFString(Method); const FString DataString = CStringToFString(Data); + // Traced back to Flutter rather than logged. The engine's log file is + // buffered and mostly shows startup, so a message that vanishes between + // here and the actor leaves nothing to read. These go through the message + // callback directly, which needs no bridge actor, so they still arrive when + // the thing being diagnosed is the bridge actor itself. + SendToFlutter_Apple(TEXT("Trace"), TEXT("queued"), + FString::Printf(TEXT("%s.%s"), *TargetString, *MethodString)); + RunOnGameThread([TargetString, MethodString, DataString]() { - if (AFlutterBridge* Bridge = GFlutterBridgeInstance.load(std::memory_order_acquire)) + AFlutterBridge* Bridge = GFlutterBridgeInstance.load(std::memory_order_acquire); + + SendToFlutter_Apple(TEXT("Trace"), TEXT("drained"), + FString::Printf(TEXT("%s.%s bridge=%s"), *TargetString, *MethodString, + Bridge != nullptr ? TEXT("yes") : TEXT("null"))); + + if (Bridge != nullptr) { Bridge->ReceiveFromFlutter(TargetString, MethodString, DataString); } @@ -342,9 +356,6 @@ void UnrealBridge_SendToUnreal(const char* Target, const char* Method, const cha TEXT("[FlutterBridge_Apple] Dropping message from Flutter, no bridge actor: Target=%s, Method=%s"), *TargetString, *MethodString); } - - UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Apple] Routed %s.%s"), - *TargetString, *MethodString); }); } diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterMessageRouter.cpp b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterMessageRouter.cpp index 4b3745a..296c7bb 100644 --- a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterMessageRouter.cpp +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterMessageRouter.cpp @@ -184,6 +184,16 @@ bool UFlutterMessageRouter::RouteMessage(const FString& Target, const FString& M return true; } + // Then the wildcard, which is what every AFlutterActor registers under. + // RegisterMethod(Target, "*", ...) is how an actor says "send me everything", + // and looking up only the exact method name means that handler can never be + // found. The actor still gets the real method name, so it can dispatch. + if (TryRouteCached(GetCacheKey(Target, TEXT("*")), Method, Data)) + { + Statistics.MessagesRouted++; + return true; + } + // Check if target is registered but method is not if (Targets.Contains(Target)) { @@ -215,6 +225,13 @@ bool UFlutterMessageRouter::RouteBinaryMessage(const FString& Target, const FStr return true; } + // Then the wildcard, for the same reason as the text path above. + if (TryRouteBinaryCached(GetCacheKey(Target, TEXT("*")), Method, Data)) + { + Statistics.MessagesRouted++; + return true; + } + // Check if target is registered but method is not if (Targets.Contains(Target)) { From 10d6668a4de055f84685d57c5914fe0d1f32820d Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 5 Sep 2026 21:43:36 -0500 Subject: [PATCH 13/25] fix(unreal): run messages from Flutter on the game thread, and stream the camera Setting the cube's colour aborted the process. Setting its speed did not, one line away in the same handler, through the same route, on the same actor. That difference is not about delivery, it is about what the two operations touch: ARotatingCube::SetColor UMaterialInstanceDynamic::SetVectorParameterValue GameThread_UpdateMIParameter FDebug::CheckVerifyFailedImpl2 Every message from Flutter was running on the main thread. TickGameThread drains its queue on whoever calls it, and the host drives that from a display link, so the work landed on the wrong thread and mostly got away with it. Writing a float to an actor is harmless. Touching a material is not, and the renderer checks its caller. The engine already drains that queue from its own core ticker, on the real game thread, so the tick now leaves it alone unless it genuinely is that thread. The call stays for a host that drives the engine itself, which is the other arrangement this ABI supports. Worth noting the two earlier routing fixes were working the whole time. They delivered messages onto a thread that had been wrong since the start; nothing had ever travelled far enough to expose it. Also streams the camera to Flutter while the viewer moves it, as Camera.moved with zoom as a fraction of the range, the raw distance, and the orbit angles. Throttled, and silent while the camera drifts on its own, since drift crosses any sensible threshold several times a second and would otherwise report continuously about a camera nobody is touching. --- .../Private/FlutterBridge_Apple.cpp | 22 +++++++-- .../Private/FlutterBridge_Apple.cpp | 22 +++++++-- .../GameFrameworkProject/FlutterDemoScene.cpp | 46 +++++++++++++++++++ .../GameFrameworkProject/FlutterDemoScene.h | 9 ++++ 4 files changed, 91 insertions(+), 8 deletions(-) diff --git a/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp b/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp index 24bceae..f43a8f7 100644 --- a/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp +++ b/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp @@ -538,10 +538,24 @@ int32_t UnrealBridge_Tick(float DeltaSeconds) } - // TickGameThread must run on the thread that owns the engine. In an - // embedded build that is whichever thread the host drives it from, so this - // deliberately does not marshal: doing so would tick from somewhere the - // engine does not expect. + // Only drain the queue when this really is the game thread. + // + // TickGameThread runs queued work on whoever calls it, and the host drives + // this from a display link on the main thread. Draining there means every + // message from Flutter runs on the wrong thread, which mostly appears to + // work: setting a float on an actor is harmless. Touching anything the + // renderer owns is not, and a material parameter aborts the process inside + // a check that the caller is the game thread. + // + // The engine already drains this queue from its own core ticker, on the + // real game thread, so the right answer here is to leave it alone. The call + // stays for a host that genuinely drives the engine from its own thread, + // which is the other embedded arrangement this ABI supports. + if (!IsInGameThread()) + { + return 0; + } + return FEmbeddedCommunication::TickGameThread(DeltaSeconds) ? 1 : 0; } diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp index 24bceae..f43a8f7 100644 --- a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp @@ -538,10 +538,24 @@ int32_t UnrealBridge_Tick(float DeltaSeconds) } - // TickGameThread must run on the thread that owns the engine. In an - // embedded build that is whichever thread the host drives it from, so this - // deliberately does not marshal: doing so would tick from somewhere the - // engine does not expect. + // Only drain the queue when this really is the game thread. + // + // TickGameThread runs queued work on whoever calls it, and the host drives + // this from a display link on the main thread. Draining there means every + // message from Flutter runs on the wrong thread, which mostly appears to + // work: setting a float on an actor is harmless. Touching anything the + // renderer owns is not, and a material parameter aborts the process inside + // a check that the caller is the game thread. + // + // The engine already drains this queue from its own core ticker, on the + // real game thread, so the right answer here is to leave it alone. The call + // stays for a host that genuinely drives the engine from its own thread, + // which is the other embedded arrangement this ABI supports. + if (!IsInGameThread()) + { + return 0; + } + return FEmbeddedCommunication::TickGameThread(DeltaSeconds) ? 1 : 0; } diff --git a/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.cpp b/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.cpp index d70428a..1fce548 100644 --- a/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.cpp +++ b/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.cpp @@ -527,6 +527,49 @@ void AFlutterDemoScene::UpdateOrbitFromTouch(float DeltaSeconds) } } +void AFlutterDemoScene::ReportCameraIfMoved() +{ + // Only once the viewer has taken the camera over. Until then it drifts on + // its own, and drift crosses any sensible threshold several times a second, + // so reporting it means a steady stream of messages about a camera nobody + // is touching. + if (!bViewerHasTakenOver) + { + return; + } + + // Then only when it actually moved, and no faster than the throttle. A + // message every frame of a drag would flood the channel and the HUD with + // values nobody can read. + const bool bMoved = + !FMath::IsNearlyEqual(OrbitDistance, LastSentDistance, 1.0f) || + !FMath::IsNearlyEqual(OrbitYaw, LastSentYaw, 0.5f) || + !FMath::IsNearlyEqual(OrbitPitch, LastSentPitch, 0.5f); + + if (!bMoved || CameraReportCooldown > 0.0f) + { + return; + } + + LastSentDistance = OrbitDistance; + LastSentYaw = OrbitYaw; + LastSentPitch = OrbitPitch; + CameraReportCooldown = 0.1f; + + // Zoom as a fraction of the range, which is what a host actually wants: + // 0 is as close as the camera goes, 1 as far. The raw distance goes too, + // for anything that needs the real units. + const float Zoom = FMath::GetRangePct(MinDistance, MaxDistance, OrbitDistance); + + if (AFlutterBridge* Bridge = AFlutterBridge::GetInstance(this)) + { + Bridge->SendToFlutter(TEXT("Camera"), TEXT("moved"), + FString::Printf( + TEXT("{\"zoom\":%.3f,\"distance\":%.0f,\"yaw\":%.1f,\"pitch\":%.1f}"), + Zoom, OrbitDistance, OrbitYaw, OrbitPitch)); + } +} + void AFlutterDemoScene::ReportRenderState() const { FString CameraState = TEXT("no camera"); @@ -587,6 +630,9 @@ void AFlutterDemoScene::Tick(float DeltaSeconds) UpdateOrbitFromTouch(DeltaSeconds); + CameraReportCooldown = FMath::Max(0.0f, CameraReportCooldown - DeltaSeconds); + ReportCameraIfMoved(); + SceneTime += DeltaSeconds; // Report what the renderer is actually doing, once a second. The view and diff --git a/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.h b/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.h index 1248115..c6eb1ab 100644 --- a/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.h +++ b/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.h @@ -64,6 +64,9 @@ class AFlutterDemoScene : public AActor /** Log what the renderer is doing, so a letterbox can be traced to a cause. */ void ReportRenderState() const; + /** Tell Flutter where the camera is, when the viewer has moved it. */ + void ReportCameraIfMoved(); + UPROPERTY(Transient) ACameraActor* Camera = nullptr; @@ -79,6 +82,12 @@ class AFlutterDemoScene : public AActor /** Throttles the render state report to once a second. */ float ReportSeconds = 0.0f; + /** Throttles camera updates, and what was last sent, so a still camera is silent. */ + float CameraReportCooldown = 0.0f; + float LastSentDistance = -1.0f; + float LastSentYaw = 0.0f; + float LastSentPitch = 0.0f; + /** Where the camera sits, in orbit terms. Yaw and pitch are degrees. */ float OrbitYaw = 0.0f; float OrbitPitch = -14.0f; From e8db970fd5e75e104ca8fe8504252965442d6533 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 5 Sep 2026 21:57:23 -0500 Subject: [PATCH 14/25] feat(unreal): receive every message from Flutter without naming a target Flutter has one place that sees all traffic: GameWidget's onMessage gets everything and decides what to do with it. Unreal had no equivalent for C++. You had to give an actor a name, register it with the router, and hope the name matched, which for a single-scene app is ceremony around a switch statement. AFlutterBridge now exposes OnAnyMessageFromFlutter, a multicast that fires for every message whatever it was addressed to, assignable from Blueprint and from C++ with AddDynamic. It broadcasts after routing, so a named handler still sees the message first and binding this takes delivery away from nothing. The router also accepts "*" as a target, so an actor can register as the catch-all rather than being given a name of its own. Tidying, both of which were debugging aids that should not have been left on. The message trace is behind a console variable now, off by default, since it doubles the traffic; turn it on from the host with executeConsoleCommand("flutter.TraceMessages 1") when a control appears to do nothing. And the demo's render state went from every three seconds to once, on the first tick after the view is claimed rather than in BeginPlay, where it reported zeroes because the viewport did not exist yet. --- .../FlutterPlugin/Private/FlutterBridge.cpp | 5 +++ .../Private/FlutterBridge_Apple.cpp | 43 ++++++++++++++----- .../Private/FlutterMessageRouter.cpp | 9 ++++ .../FlutterPlugin/Public/FlutterBridge.h | 22 ++++++++++ .../FlutterPlugin/Private/FlutterBridge.cpp | 5 +++ .../Private/FlutterBridge_Apple.cpp | 43 ++++++++++++++----- .../Private/FlutterMessageRouter.cpp | 9 ++++ .../FlutterPlugin/Public/FlutterBridge.h | 22 ++++++++++ .../GameFrameworkProject/FlutterDemoScene.cpp | 20 +++++---- .../GameFrameworkProject/FlutterDemoScene.h | 4 +- 10 files changed, 151 insertions(+), 31 deletions(-) diff --git a/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge.cpp b/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge.cpp index 563e370..94724e2 100644 --- a/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge.cpp +++ b/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge.cpp @@ -183,6 +183,11 @@ void AFlutterBridge::ReceiveFromFlutter(const FString& Target, const FString& Me // Fire Blueprint event OnMessageFromFlutter(Target, Method, Data); + + // And the unified one, which fires for everything regardless of target. + // Deliberately after routing, so a named handler still sees the message + // first and binding this takes delivery away from nothing. + OnAnyMessageFromFlutter.Broadcast(Target, Method, Data); } // ============================================================ diff --git a/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp b/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp index f43a8f7..f10c4a4 100644 --- a/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp +++ b/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp @@ -8,6 +8,7 @@ #include "Async/Async.h" #include "Misc/EmbeddedCommunication.h" #include "Misc/CoreDelegates.h" +#include "HAL/IConsoleManager.h" #include "Misc/ConfigCacheIni.h" #include @@ -32,6 +33,25 @@ static std::atomic GFlutterBridgeInstance{nullptr}; static std::atomic GMessageCallback{nullptr}; + +/// Trace every message from Flutter back to Flutter, as Trace.queued and +/// Trace.drained. +/// +/// Off by default, because it doubles the message traffic. Worth turning on +/// when a control appears to do nothing: the engine's log file is buffered and +/// mostly shows startup, so a message that disappears between the bridge and an +/// actor otherwise leaves nothing to read. Enable it from the host with +/// executeConsoleCommand("flutter.TraceMessages 1"). +static TAutoConsoleVariable CVarTraceMessages( + TEXT("flutter.TraceMessages"), + 0, + TEXT("Echo each message from Flutter back as Trace.queued and Trace.drained."), + ECVF_Default); + +static bool ShouldTraceMessages() +{ + return CVarTraceMessages.GetValueOnAnyThread() != 0; +} static std::atomic GBinaryCallback{nullptr}; /// Cached quality settings, refreshed on the game thread. @@ -330,21 +350,24 @@ void UnrealBridge_SendToUnreal(const char* Target, const char* Method, const cha const FString MethodString = CStringToFString(Method); const FString DataString = CStringToFString(Data); - // Traced back to Flutter rather than logged. The engine's log file is - // buffered and mostly shows startup, so a message that vanishes between - // here and the actor leaves nothing to read. These go through the message - // callback directly, which needs no bridge actor, so they still arrive when - // the thing being diagnosed is the bridge actor itself. - SendToFlutter_Apple(TEXT("Trace"), TEXT("queued"), - FString::Printf(TEXT("%s.%s"), *TargetString, *MethodString)); + // Traced through the message callback rather than the bridge actor, so it + // still reports when the thing being diagnosed is the bridge actor itself. + if (ShouldTraceMessages()) + { + SendToFlutter_Apple(TEXT("Trace"), TEXT("queued"), + FString::Printf(TEXT("%s.%s"), *TargetString, *MethodString)); + } RunOnGameThread([TargetString, MethodString, DataString]() { AFlutterBridge* Bridge = GFlutterBridgeInstance.load(std::memory_order_acquire); - SendToFlutter_Apple(TEXT("Trace"), TEXT("drained"), - FString::Printf(TEXT("%s.%s bridge=%s"), *TargetString, *MethodString, - Bridge != nullptr ? TEXT("yes") : TEXT("null"))); + if (ShouldTraceMessages()) + { + SendToFlutter_Apple(TEXT("Trace"), TEXT("drained"), + FString::Printf(TEXT("%s.%s bridge=%s"), *TargetString, *MethodString, + Bridge != nullptr ? TEXT("yes") : TEXT("null"))); + } if (Bridge != nullptr) { diff --git a/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterMessageRouter.cpp b/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterMessageRouter.cpp index 296c7bb..cacd7d5 100644 --- a/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterMessageRouter.cpp +++ b/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterMessageRouter.cpp @@ -194,6 +194,15 @@ bool UFlutterMessageRouter::RouteMessage(const FString& Target, const FString& M return true; } + // Then a catch-all target, for an actor that registered under "*" to take + // everything rather than being given a name of its own. + if (TryRouteCached(GetCacheKey(TEXT("*"), Method), Method, Data) || + TryRouteCached(GetCacheKey(TEXT("*"), TEXT("*")), Method, Data)) + { + Statistics.MessagesRouted++; + return true; + } + // Check if target is registered but method is not if (Targets.Contains(Target)) { diff --git a/engines/unreal/plugin/Source/FlutterPlugin/Public/FlutterBridge.h b/engines/unreal/plugin/Source/FlutterPlugin/Public/FlutterBridge.h index b74cfb6..3f27010 100644 --- a/engines/unreal/plugin/Source/FlutterPlugin/Public/FlutterBridge.h +++ b/engines/unreal/plugin/Source/FlutterPlugin/Public/FlutterBridge.h @@ -13,6 +13,12 @@ * Handles bidirectional communication, console commands, quality settings, * and level loading. */ +/** + * Every message from Flutter, whatever it was addressed to. + */ +DECLARE_DYNAMIC_MULTICAST_DELEGATE_ThreeParams(FFlutterAnyMessage, + const FString&, Target, const FString&, Method, const FString&, Data); + UCLASS(Blueprintable, BlueprintType) class FLUTTERPLUGIN_API AFlutterBridge : public AActor { @@ -56,6 +62,22 @@ class FLUTTERPLUGIN_API AFlutterBridge : public AActor UFUNCTION(BlueprintImplementableEvent, Category = "Flutter") void OnMessageFromFlutter(const FString& Target, const FString& Method, const FString& Data); + /** + * Every message from Flutter, whatever it was addressed to. + * + * The same shape as the Flutter side, where GameWidget's onMessage receives + * everything and decides what to do with it. Bind this when you would + * rather switch on the target yourself than give an actor a name and + * register it, which is most of the time for a single-scene app. + * + * Fires for every message, including ones that a named target also handled, + * so binding it does not take delivery away from anything else. + * + * Assignable from Blueprint, and from C++ with AddDynamic. + */ + UPROPERTY(BlueprintAssignable, Category = "Flutter") + FFlutterAnyMessage OnAnyMessageFromFlutter; + // ============================================================ // MARK: - Binary Message Communication // ============================================================ diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge.cpp b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge.cpp index 563e370..94724e2 100644 --- a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge.cpp +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge.cpp @@ -183,6 +183,11 @@ void AFlutterBridge::ReceiveFromFlutter(const FString& Target, const FString& Me // Fire Blueprint event OnMessageFromFlutter(Target, Method, Data); + + // And the unified one, which fires for everything regardless of target. + // Deliberately after routing, so a named handler still sees the message + // first and binding this takes delivery away from nothing. + OnAnyMessageFromFlutter.Broadcast(Target, Method, Data); } // ============================================================ diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp index f43a8f7..f10c4a4 100644 --- a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp @@ -8,6 +8,7 @@ #include "Async/Async.h" #include "Misc/EmbeddedCommunication.h" #include "Misc/CoreDelegates.h" +#include "HAL/IConsoleManager.h" #include "Misc/ConfigCacheIni.h" #include @@ -32,6 +33,25 @@ static std::atomic GFlutterBridgeInstance{nullptr}; static std::atomic GMessageCallback{nullptr}; + +/// Trace every message from Flutter back to Flutter, as Trace.queued and +/// Trace.drained. +/// +/// Off by default, because it doubles the message traffic. Worth turning on +/// when a control appears to do nothing: the engine's log file is buffered and +/// mostly shows startup, so a message that disappears between the bridge and an +/// actor otherwise leaves nothing to read. Enable it from the host with +/// executeConsoleCommand("flutter.TraceMessages 1"). +static TAutoConsoleVariable CVarTraceMessages( + TEXT("flutter.TraceMessages"), + 0, + TEXT("Echo each message from Flutter back as Trace.queued and Trace.drained."), + ECVF_Default); + +static bool ShouldTraceMessages() +{ + return CVarTraceMessages.GetValueOnAnyThread() != 0; +} static std::atomic GBinaryCallback{nullptr}; /// Cached quality settings, refreshed on the game thread. @@ -330,21 +350,24 @@ void UnrealBridge_SendToUnreal(const char* Target, const char* Method, const cha const FString MethodString = CStringToFString(Method); const FString DataString = CStringToFString(Data); - // Traced back to Flutter rather than logged. The engine's log file is - // buffered and mostly shows startup, so a message that vanishes between - // here and the actor leaves nothing to read. These go through the message - // callback directly, which needs no bridge actor, so they still arrive when - // the thing being diagnosed is the bridge actor itself. - SendToFlutter_Apple(TEXT("Trace"), TEXT("queued"), - FString::Printf(TEXT("%s.%s"), *TargetString, *MethodString)); + // Traced through the message callback rather than the bridge actor, so it + // still reports when the thing being diagnosed is the bridge actor itself. + if (ShouldTraceMessages()) + { + SendToFlutter_Apple(TEXT("Trace"), TEXT("queued"), + FString::Printf(TEXT("%s.%s"), *TargetString, *MethodString)); + } RunOnGameThread([TargetString, MethodString, DataString]() { AFlutterBridge* Bridge = GFlutterBridgeInstance.load(std::memory_order_acquire); - SendToFlutter_Apple(TEXT("Trace"), TEXT("drained"), - FString::Printf(TEXT("%s.%s bridge=%s"), *TargetString, *MethodString, - Bridge != nullptr ? TEXT("yes") : TEXT("null"))); + if (ShouldTraceMessages()) + { + SendToFlutter_Apple(TEXT("Trace"), TEXT("drained"), + FString::Printf(TEXT("%s.%s bridge=%s"), *TargetString, *MethodString, + Bridge != nullptr ? TEXT("yes") : TEXT("null"))); + } if (Bridge != nullptr) { diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterMessageRouter.cpp b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterMessageRouter.cpp index 296c7bb..cacd7d5 100644 --- a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterMessageRouter.cpp +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterMessageRouter.cpp @@ -194,6 +194,15 @@ bool UFlutterMessageRouter::RouteMessage(const FString& Target, const FString& M return true; } + // Then a catch-all target, for an actor that registered under "*" to take + // everything rather than being given a name of its own. + if (TryRouteCached(GetCacheKey(TEXT("*"), Method), Method, Data) || + TryRouteCached(GetCacheKey(TEXT("*"), TEXT("*")), Method, Data)) + { + Statistics.MessagesRouted++; + return true; + } + // Check if target is registered but method is not if (Targets.Contains(Target)) { diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/FlutterBridge.h b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/FlutterBridge.h index b74cfb6..3f27010 100644 --- a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/FlutterBridge.h +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/FlutterBridge.h @@ -13,6 +13,12 @@ * Handles bidirectional communication, console commands, quality settings, * and level loading. */ +/** + * Every message from Flutter, whatever it was addressed to. + */ +DECLARE_DYNAMIC_MULTICAST_DELEGATE_ThreeParams(FFlutterAnyMessage, + const FString&, Target, const FString&, Method, const FString&, Data); + UCLASS(Blueprintable, BlueprintType) class FLUTTERPLUGIN_API AFlutterBridge : public AActor { @@ -56,6 +62,22 @@ class FLUTTERPLUGIN_API AFlutterBridge : public AActor UFUNCTION(BlueprintImplementableEvent, Category = "Flutter") void OnMessageFromFlutter(const FString& Target, const FString& Method, const FString& Data); + /** + * Every message from Flutter, whatever it was addressed to. + * + * The same shape as the Flutter side, where GameWidget's onMessage receives + * everything and decides what to do with it. Bind this when you would + * rather switch on the target yourself than give an actor a name and + * register it, which is most of the time for a single-scene app. + * + * Fires for every message, including ones that a named target also handled, + * so binding it does not take delivery away from anything else. + * + * Assignable from Blueprint, and from C++ with AddDynamic. + */ + UPROPERTY(BlueprintAssignable, Category = "Flutter") + FFlutterAnyMessage OnAnyMessageFromFlutter; + // ============================================================ // MARK: - Binary Message Communication // ============================================================ diff --git a/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.cpp b/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.cpp index 1fce548..3ed7629 100644 --- a/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.cpp +++ b/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.cpp @@ -424,6 +424,7 @@ void AFlutterDemoScene::BeginPlay() UE_LOG(LogTemp, Log, TEXT("[FlutterDemoScene] Scene built: %d orbiters, cube %s"), Orbiters.Num(), HeroCube ? TEXT("yes") : TEXT("no")); + } void AFlutterDemoScene::TakeOverTheView() @@ -628,6 +629,16 @@ void AFlutterDemoScene::Tick(float DeltaSeconds) return; } + // Report once, the first tick after the view is ours. Doing it in BeginPlay + // reports zeroes, because the viewport does not exist yet, which is worse + // than not reporting at all. Useful for telling a letterbox from a camera + // framing problem when someone says the scene looks wrong. + if (!bReportedRenderState) + { + bReportedRenderState = true; + ReportRenderState(); + } + UpdateOrbitFromTouch(DeltaSeconds); CameraReportCooldown = FMath::Max(0.0f, CameraReportCooldown - DeltaSeconds); @@ -635,15 +646,6 @@ void AFlutterDemoScene::Tick(float DeltaSeconds) SceneTime += DeltaSeconds; - // Report what the renderer is actually doing, once a second. The view and - // the Metal surface measure correctly from the host side, so anything that - // still letterboxes has to be visible from in here. - ReportSeconds += DeltaSeconds; - if (ReportSeconds >= 3.0f) - { - ReportSeconds = 0.0f; - ReportRenderState(); - } const int32 Count = Orbiters.Num(); for (int32 Index = 0; Index < Count; ++Index) diff --git a/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.h b/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.h index c6eb1ab..4078fcf 100644 --- a/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.h +++ b/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.h @@ -79,8 +79,8 @@ class AFlutterDemoScene : public AActor /** Seconds since BeginPlay, which drives the orbit of the shapes. */ float SceneTime = 0.0f; - /** Throttles the render state report to once a second. */ - float ReportSeconds = 0.0f; + /** Whether the one-off render state report has gone out. */ + bool bReportedRenderState = false; /** Throttles camera updates, and what was last sent, so a still camera is silent. */ float CameraReportCooldown = 0.0f; From 16da5ba0dff34b736ded0cb1e94a80a584d4d6a1 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 5 Sep 2026 22:31:43 -0500 Subject: [PATCH 15/25] chore(example): let the CLI own the Podfile copy phase Written by "game sync unreal -p ios" now rather than by hand, which is the point of the change in game-cli that generates it. --- example/ios/Podfile | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/example/ios/Podfile b/example/ios/Podfile index b320f7b..d45e97d 100644 --- a/example/ios/Podfile +++ b/example/ios/Podfile @@ -36,6 +36,30 @@ target 'Runner' do end post_install do |installer| + # >>> gameframework: unreal content >>> + installer.aggregate_targets.each do |aggregate| + aggregate.user_project.targets.each do |target| + next unless target.name == 'Runner' + next if target.shell_script_build_phases.any? { |p| p.name == 'Copy Unreal content' } + + phase = target.new_shell_script_build_phase('Copy Unreal content') + phase.shell_script = <<~SCRIPT + set -e + CONTENT="${BUILT_PRODUCTS_DIR}/${FRAMEWORKS_FOLDER_PATH}/gameframework_unreal.framework" + DEST="${BUILT_PRODUCTS_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}" + if [ -d "${CONTENT}/cookeddata" ]; then + rsync -a --delete "${CONTENT}/cookeddata" "${DEST}/" + for f in uecommandline.txt mute.caf notices.txt; do + [ -f "${CONTENT}/${f}" ] && cp -f "${CONTENT}/${f}" "${DEST}/" + done + else + echo "warning: no Unreal cooked content; run 'game sync unreal -p ios'" + fi + SCRIPT + end + aggregate.user_project.save + end + # <<< gameframework: unreal content <<< installer.pods_project.targets.each do |target| flutter_additional_ios_build_settings(target) end From d36c35982077d08aef92a7a1dd5093a9f57fc71d Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 5 Sep 2026 22:39:44 -0500 Subject: [PATCH 16/25] fix(example): report the render state only once there is one It printed client=0x0 scene=0x0, because claiming the view happens during BeginPlay and the viewport does not exist until later, so the first tick always beat it. A diagnostic that reports zeroes is worse than one that says nothing: it looks like an answer. Waits for a viewport with a size now. --- .../GameFrameworkProject/FlutterDemoScene.cpp | 23 +++++++++++++++---- .../GameFrameworkProject/FlutterDemoScene.h | 3 +++ 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.cpp b/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.cpp index 3ed7629..02338f1 100644 --- a/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.cpp +++ b/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.cpp @@ -528,6 +528,18 @@ void AFlutterDemoScene::UpdateOrbitFromTouch(float DeltaSeconds) } } +bool AFlutterDemoScene::HasSizedViewport() const +{ + if (GEngine == nullptr || GEngine->GameViewport == nullptr) + { + return false; + } + + FVector2D Size = FVector2D::ZeroVector; + GEngine->GameViewport->GetViewportSize(Size); + return Size.X > 0.0f && Size.Y > 0.0f; +} + void AFlutterDemoScene::ReportCameraIfMoved() { // Only once the viewer has taken the camera over. Until then it drifts on @@ -629,11 +641,12 @@ void AFlutterDemoScene::Tick(float DeltaSeconds) return; } - // Report once, the first tick after the view is ours. Doing it in BeginPlay - // reports zeroes, because the viewport does not exist yet, which is worse - // than not reporting at all. Useful for telling a letterbox from a camera - // framing problem when someone says the scene looks wrong. - if (!bReportedRenderState) + // Report once, and only once there is something real to report. Claiming + // the view happens during BeginPlay, well before the viewport exists, so + // keying off that still prints zeroes, which is worse than not reporting at + // all. Wait for a viewport with a size. Useful for telling a letterbox from + // a camera framing problem when someone says the scene looks wrong. + if (!bReportedRenderState && HasSizedViewport()) { bReportedRenderState = true; ReportRenderState(); diff --git a/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.h b/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.h index 4078fcf..53a73a9 100644 --- a/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.h +++ b/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.h @@ -67,6 +67,9 @@ class AFlutterDemoScene : public AActor /** Tell Flutter where the camera is, when the viewer has moved it. */ void ReportCameraIfMoved(); + /** Whether the viewport exists and has a size worth reporting. */ + bool HasSizedViewport() const; + UPROPERTY(Transient) ACameraActor* Camera = nullptr; From 907f2ef3af2d0ec0c72346d55622a97719480ffa Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 5 Sep 2026 23:08:11 -0500 Subject: [PATCH 17/25] fix(example): frame the camera once the viewport has a size The render state reports client=804x1748 now, which is right, and alongside it fov=70. That is the landscape value. PositionCamera runs during BeginPlay, where asking for the aspect ratio gives nothing, so it settles on the wrong field of view and the scene starts tighter than intended. Easy to miss, because it corrects itself the moment you drag. Frames it again on the first tick where the viewport has a size, next to the report that exposed it. --- .../demo/Source/GameFrameworkProject/FlutterDemoScene.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.cpp b/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.cpp index 02338f1..d6b42e2 100644 --- a/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.cpp +++ b/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.cpp @@ -649,6 +649,13 @@ void AFlutterDemoScene::Tick(float DeltaSeconds) if (!bReportedRenderState && HasSizedViewport()) { bReportedRenderState = true; + + // Frame it again now the viewport has a size. The first attempt runs + // during BeginPlay, when asking for the aspect gives nothing, so the + // camera settles on the landscape field of view and the scene starts + // tighter than intended. It corrects itself the moment you drag, which + // makes it easy to miss. + PositionCamera(); ReportRenderState(); } From ed3584ca7341e103680aa21eefe644ff3f23044c Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sun, 6 Sep 2026 10:35:49 -0500 Subject: [PATCH 18/25] test(example): prove the unified handler receives what the router cannot Binds OnAnyMessageFromFlutter in the demo scene, which never registers a name for itself, and adds a "reset view" button that sends to NoSuchActor. No actor is registered under that, so the router drops it, which is the point. On device: Scene.saw NoSuchActor.resetCamera The camera resets. A message reached an actor through a path the named routing could not deliver, and the scene handled it without telling Flutter what it is called, the same shape as GameWidget's onMessage on the other side. The two also coexist, which was the other thing worth knowing. Every message to the cube now produces both GameFrameworkDemo.onColorChanged from the router and Scene.saw from the bridge, so binding this takes delivery away from nothing. --- .../GameFrameworkProject/FlutterDemoScene.cpp | 44 +++++++++++++++++++ .../GameFrameworkProject/FlutterDemoScene.h | 12 +++++ 2 files changed, 56 insertions(+) diff --git a/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.cpp b/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.cpp index d6b42e2..d846589 100644 --- a/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.cpp +++ b/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.cpp @@ -422,11 +422,55 @@ void AFlutterDemoScene::BeginPlay() BuildCamera(); TakeOverTheView(); + // Take everything from Flutter, without registering a name for this actor. + // The cube is reached through the router because it has a name; this is the + // other way, and the two do not compete: the router runs first and this + // still sees the message afterwards. + if (AFlutterBridge* Bridge = AFlutterBridge::GetInstance(this)) + { + Bridge->OnAnyMessageFromFlutter.AddDynamic(this, &AFlutterDemoScene::OnAnyFlutterMessage); + } + UE_LOG(LogTemp, Log, TEXT("[FlutterDemoScene] Scene built: %d orbiters, cube %s"), Orbiters.Num(), HeroCube ? TEXT("yes") : TEXT("no")); } +void AFlutterDemoScene::EndPlay(const EEndPlayReason::Type Reason) +{ + if (AFlutterBridge* Bridge = AFlutterBridge::GetInstance(this)) + { + Bridge->OnAnyMessageFromFlutter.RemoveDynamic(this, &AFlutterDemoScene::OnAnyFlutterMessage); + } + + Super::EndPlay(Reason); +} + +void AFlutterDemoScene::OnAnyFlutterMessage(const FString& Target, const FString& Method, + const FString& Data) +{ + // Answer so the host can see this fired, and for which message. Sent back + // under a name of our choosing; nothing had to be registered to receive. + if (AFlutterBridge* Bridge = AFlutterBridge::GetInstance(this)) + { + Bridge->SendToFlutter(TEXT("Scene"), TEXT("saw"), + FString::Printf(TEXT("%s.%s"), *Target, *Method)); + } + + // A scene-level command, addressed to whatever you like. No actor is + // registered under any of it, so the router drops it and this still runs. + if (Method == TEXT("resetCamera")) + { + OrbitYaw = 0.0f; + OrbitPitch = -14.0f; + OrbitDistance = 640.0f; + bViewerHasTakenOver = true; + PositionCamera(); + + UE_LOG(LogTemp, Log, TEXT("[FlutterDemoScene] Camera reset, asked for by %s"), *Target); + } +} + void AFlutterDemoScene::TakeOverTheView() { if (bViewClaimed || Camera == nullptr) diff --git a/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.h b/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.h index 53a73a9..e0c72d4 100644 --- a/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.h +++ b/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.h @@ -30,8 +30,20 @@ class AFlutterDemoScene : public AActor AFlutterDemoScene(); virtual void BeginPlay() override; + virtual void EndPlay(const EEndPlayReason::Type Reason) override; virtual void Tick(float DeltaSeconds) override; + /** + * Every message from Flutter, whatever it was addressed to. + * + * Bound to the bridge rather than registered with the router under a name, + * which is the point: this scene never tells Flutter what it is called and + * still sees everything. Handles messages for targets no actor registered, + * which the router alone would drop. + */ + UFUNCTION() + void OnAnyFlutterMessage(const FString& Target, const FString& Method, const FString& Data); + /** The hero cube, which is what Flutter talks to. */ UPROPERTY(Transient) ARotatingCube* HeroCube = nullptr; From 01e9c4a8e2f07311cd8dac644bcdc87e1b0b250f Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sun, 6 Sep 2026 14:15:05 -0500 Subject: [PATCH 19/25] feat(unreal): pause the game for real, and let a host unload the engine Pause did not pause. OnEnginePause set a flag and fired a Blueprint event, which stops nothing: actors keep ticking, time keeps advancing, and the only things that appear to pause are whatever checked the flag themselves. That is why it looked like it paused the cube and nothing else. Unity calls pause on the player and the whole thing stops, so this now calls UGameplayStatics::SetGamePaused and drops the keep-awake with it. A paused game that still renders every frame costs the same battery as a running one, which rather defeats the point on a phone. Unload is new, and honest about what Unreal allows. Unity's own comment says iOS cannot unload without destroying, so it pauses instead; Unreal has the same limit and this does not pretend otherwise. What it can do is stop: the game pauses, and the render view goes away, which frees the drawable and its buffers. That is most of what an idle engine costs while you are looking at some other Flutter page. Reversible through reload(), which is a real API the whole way down rather than a message: Dart, the channel, Swift, the pod. Two things bit while building it. Releasing the view is not enough on its own, because offering the engine a view is exactly what the tick is for, so it rebuilt one on the next frame. Unload needs to suppress that until reload, not just destroy once. And AllowSleep is matched: it asserts when released without a KeepAwake to match. Releasing it from both the pod and the bridge against a single keep-awake aborted the process on unload. The engine side owns that pairing now, and pause and resume are idempotent, which matters on its own because a page lifecycle calls pause more than once. The demo's Scene.saw echo is behind flutter.TraceMessages with the rest of the tracing, and the lifecycle controls sit in the header where they cannot be pushed off the bottom of a panel. On device: unload, reload, and unload again, with the view rebuilt three milliseconds after reload and no crash on the repeat. --- .../unreal/dart/ios/Classes/UnrealBridge.mm | 43 +++++++++++- .../ios/Classes/UnrealEngineController.swift | 69 ++++++++++++++++++- .../dart/lib/src/unreal_controller.dart | 20 ++++++ .../FlutterPlugin/Private/FlutterBridge.cpp | 37 ++++++++++ .../FlutterPlugin/Private/FlutterBridge.cpp | 37 ++++++++++ .../GameFrameworkProject/FlutterDemoScene.cpp | 19 +++-- .../Classes/Core/GameEngineController.swift | 11 +++ 7 files changed, 227 insertions(+), 9 deletions(-) diff --git a/engines/unreal/dart/ios/Classes/UnrealBridge.mm b/engines/unreal/dart/ios/Classes/UnrealBridge.mm index a0ab100..eb20547 100644 --- a/engines/unreal/dart/ios/Classes/UnrealBridge.mm +++ b/engines/unreal/dart/ios/Classes/UnrealBridge.mm @@ -200,6 +200,14 @@ static void HandleUnrealBinary(const char* target, const char* method, // Ticking happens on the main thread. That is where the host lives, and where // an embedded engine expects to be driven from. +/// Set while the host has unloaded, so the tick stops offering a view. +/// +/// Without this, releasing the view and returning to the run loop rebuilds it +/// on the very next frame, because offering one is exactly what the tick is +/// for. The engine is still running and still wants a view; the host just does +/// not want to give it one for now. +static BOOL GViewSuppressed = NO; + // Defined below, once the UnrealBridge class exists. static void BuildViewNowThatEngineIsReady(void); static BOOL HasEngineView(void); @@ -210,7 +218,7 @@ static void UnrealTick(double deltaSeconds) { // offering one until it takes. This is also the only workable moment: too // early and Metal is not up, and the readiness announcement that would // otherwise tell us cannot reach a plugin that has not loaded yet. - if (!HasEngineView()) { + if (!GViewSuppressed && !HasEngineView()) { BuildViewNowThatEngineIsReady(); } @@ -222,6 +230,7 @@ static void UnrealTick(double deltaSeconds) { /// CADisplayLink already fires on the main run loop, so no hop is needed. static CADisplayLink* GDisplayLink = nil; + static CFTimeInterval GLastTickTime = 0; @interface UnrealTicker : NSObject @@ -297,6 +306,8 @@ - (UIView*)getView; - (void)pause; - (void)resume; - (void)quit; +- (void)destroyView; +- (void)restoreView; - (void)sendMessageWithTarget:(NSString*)target method:(NSString*)method data:(NSString*)data; - (void)sendBinaryWithTarget:(NSString*)target method:(NSString*)method data:(NSData*)data; - (void)executeConsoleCommand:(NSString*)command; @@ -431,6 +442,36 @@ - (void)resume { if (pause) pause(0); } +- (void)destroyView { + GViewSuppressed = YES; + + DestroyViewFn destroyView = UNREAL_FN(DestroyViewFn, "UnrealBridge_DestroyView"); + if (destroyView) destroyView(); + + // Dropped here too, so the engine's own reference is the only one left and + // the buffers actually go back. + self.engineView = nil; + + // Letting the engine idle is the pause's job, not this one. The sleep + // counter is matched, and releasing it twice against a single KeepAwake + // asserts inside the engine, so only one side may own it. Unload pauses as + // well as releasing the view, so it is already covered. + NSLog(@"[UnrealBridge] Render view released"); +} + +- (void)restoreView { + if (!GViewSuppressed) { + return; + } + + GViewSuppressed = NO; + + // The tick offers a view again from here, the same way it did at startup. + // Waking the engine is the resume's job, for the same reason releasing was + // the pause's. + NSLog(@"[UnrealBridge] Render view will be rebuilt"); +} + - (void)quit { StopTicking(); diff --git a/engines/unreal/dart/ios/Classes/UnrealEngineController.swift b/engines/unreal/dart/ios/Classes/UnrealEngineController.swift index 180333c..c84d3b5 100644 --- a/engines/unreal/dart/ios/Classes/UnrealEngineController.swift +++ b/engines/unreal/dart/ios/Classes/UnrealEngineController.swift @@ -13,6 +13,11 @@ public class UnrealEngineController: GameEngineController { // MARK: - Properties private var unrealView: UIView? + + /// Whether unloadEngine gave the view back. Guards reload, so calling it on + /// a running engine does nothing rather than resuming something that was + /// never paused. + private var isUnloaded = false private var unrealReady = false // Message queue for events before Flutter subscribes @@ -217,15 +222,57 @@ public class UnrealEngineController: GameEngineController { } } + /// Give back everything an idle engine is holding, short of tearing it down. + /// + /// Unreal cannot be unloaded and started again in one process, so this is + /// not a teardown. What it can do is stop: the game pauses, the tick stops, + /// and the render view goes away, which is the expensive part. That frees + /// the drawable and its buffers, which on a phone is most of what an engine + /// costs while you are looking at some other Flutter page. + /// + /// Reversible. Call reload, or just show the widget again, and the view is + /// rebuilt and the engine resumes with the scene as you left it. public override func unloadEngine() { DispatchQueue.main.async { [weak self] in guard let self = self else { return } - // Unreal doesn't support unloading without destroying, pause instead - self.pauseEngine() + NSLog("UnrealEngineController: Unloading Unreal (pausing, releasing the view)") + + if let bridge = self.getUnrealBridge() { + self.callBridgePause(bridge: bridge) + self.callBridgeDestroyView(bridge: bridge) + } + + self.removeEngineView() + self.unrealView = nil + self._isPaused = true + self.isUnloaded = true + self.sendEvent(name: "onUnloaded", data: nil) } } + + /// Bring back what unloadEngine gave up. + /// + /// The engine was never destroyed, so there is nothing to start: the view + /// is rebuilt on the next tick and the game unpauses. + public override func reloadEngine() { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + guard self.isUnloaded else { return } + + NSLog("UnrealEngineController: Reloading Unreal") + self.isUnloaded = false + + if let bridge = self.getUnrealBridge() { + self.callBridgeRestoreView(bridge: bridge) + self.callBridgeResume(bridge: bridge) + } + + self._isPaused = false + self.sendEvent(name: "onLoaded", data: nil) + } + } public override func destroyEngine() { // Unregister as active controller @@ -328,6 +375,24 @@ public class UnrealEngineController: GameEngineController { bridge.perform(selector) } + private func callBridgeRestoreView(bridge: AnyObject) { + let selector = NSSelectorFromString("restoreView") + guard bridge.responds(to: selector) else { return } + + let method = bridge.method(for: selector) + typealias RestoreFunc = @convention(c) (AnyObject, Selector) -> Void + unsafeBitCast(method, to: RestoreFunc.self)(bridge, selector) + } + + private func callBridgeDestroyView(bridge: AnyObject) { + let selector = NSSelectorFromString("destroyView") + guard bridge.responds(to: selector) else { return } + + let method = bridge.method(for: selector) + typealias DestroyFunc = @convention(c) (AnyObject, Selector) -> Void + unsafeBitCast(method, to: DestroyFunc.self)(bridge, selector) + } + private func callBridgeResume(bridge: AnyObject) { let selector = NSSelectorFromString("resume") guard bridge.responds(to: selector) else { return } diff --git a/engines/unreal/dart/lib/src/unreal_controller.dart b/engines/unreal/dart/lib/src/unreal_controller.dart index 802533c..b8741fc 100644 --- a/engines/unreal/dart/lib/src/unreal_controller.dart +++ b/engines/unreal/dart/lib/src/unreal_controller.dart @@ -301,6 +301,26 @@ class UnrealController implements GameEngineController { } @override + /// Bring the engine back after [unload]. + /// + /// Unreal is never actually torn down, so there is nothing to start: the + /// render view is rebuilt and the game unpauses with the scene as you left + /// it. Calling this on an engine that was not unloaded does nothing. + Future reload() async { + _throwIfDisposed(); + + try { + await _channel.invokeMethod('engine#reload'); + } catch (e) { + throw EngineCommunicationException( + 'Failed to reload Unreal: $e', + target: 'UnrealController', + method: 'reload', + engineType: engineType, + ); + } + } + Future unload() async { _throwIfDisposed(); _throwIfNotReady(); diff --git a/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge.cpp b/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge.cpp index 94724e2..72b61dc 100644 --- a/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge.cpp +++ b/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge.cpp @@ -2,6 +2,7 @@ #include "FlutterBridge.h" #include "FlutterMessageRouter.h" +#include "Misc/EmbeddedCommunication.h" #include "Engine/World.h" #include "Engine/Engine.h" #include "Engine/GameViewportClient.h" @@ -510,15 +511,51 @@ void AFlutterBridge::OnLevelLoaded() void AFlutterBridge::OnEnginePause() { + // Pausing twice is not harmless. The sleep counter is matched, and + // AllowSleep asserts when it is released without a KeepAwake to match, so a + // second pause aborts the process rather than doing nothing. + if (bIsPaused) + { + return; + } + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] Engine paused")); bIsPaused = true; + + // Actually pause the game, rather than only recording that somebody asked. + // Setting a flag and firing a Blueprint event stops nothing: actors keep + // ticking, time keeps advancing, and the only things that appear to pause + // are the ones that happened to check the flag themselves. + if (UWorld* World = GetWorld()) + { + UGameplayStatics::SetGamePaused(World, true); + } + + // And stop driving the engine. A paused game that still renders every frame + // costs the same battery as a running one, which rather defeats the point + // on a phone. + FEmbeddedCommunication::AllowSleep(TEXT("flutter")); + OnEnginePausedBP(); } void AFlutterBridge::OnEngineResume() { + if (!bIsPaused) + { + return; + } + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] Engine resumed")); bIsPaused = false; + + FEmbeddedCommunication::KeepAwake(TEXT("flutter"), true); + + if (UWorld* World = GetWorld()) + { + UGameplayStatics::SetGamePaused(World, false); + } + OnEngineResumedBP(); } diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge.cpp b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge.cpp index 94724e2..72b61dc 100644 --- a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge.cpp +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge.cpp @@ -2,6 +2,7 @@ #include "FlutterBridge.h" #include "FlutterMessageRouter.h" +#include "Misc/EmbeddedCommunication.h" #include "Engine/World.h" #include "Engine/Engine.h" #include "Engine/GameViewportClient.h" @@ -510,15 +511,51 @@ void AFlutterBridge::OnLevelLoaded() void AFlutterBridge::OnEnginePause() { + // Pausing twice is not harmless. The sleep counter is matched, and + // AllowSleep asserts when it is released without a KeepAwake to match, so a + // second pause aborts the process rather than doing nothing. + if (bIsPaused) + { + return; + } + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] Engine paused")); bIsPaused = true; + + // Actually pause the game, rather than only recording that somebody asked. + // Setting a flag and firing a Blueprint event stops nothing: actors keep + // ticking, time keeps advancing, and the only things that appear to pause + // are the ones that happened to check the flag themselves. + if (UWorld* World = GetWorld()) + { + UGameplayStatics::SetGamePaused(World, true); + } + + // And stop driving the engine. A paused game that still renders every frame + // costs the same battery as a running one, which rather defeats the point + // on a phone. + FEmbeddedCommunication::AllowSleep(TEXT("flutter")); + OnEnginePausedBP(); } void AFlutterBridge::OnEngineResume() { + if (!bIsPaused) + { + return; + } + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] Engine resumed")); bIsPaused = false; + + FEmbeddedCommunication::KeepAwake(TEXT("flutter"), true); + + if (UWorld* World = GetWorld()) + { + UGameplayStatics::SetGamePaused(World, false); + } + OnEngineResumedBP(); } diff --git a/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.cpp b/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.cpp index d846589..3754d8a 100644 --- a/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.cpp +++ b/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.cpp @@ -14,6 +14,7 @@ #include "Components/StaticMeshComponent.h" #include "Engine/DirectionalLight.h" #include "Engine/Engine.h" +#include "HAL/IConsoleManager.h" #include "Engine/ExponentialHeightFog.h" #include "Engine/SkyLight.h" #include "Engine/StaticMesh.h" @@ -449,12 +450,18 @@ void AFlutterDemoScene::EndPlay(const EEndPlayReason::Type Reason) void AFlutterDemoScene::OnAnyFlutterMessage(const FString& Target, const FString& Method, const FString& Data) { - // Answer so the host can see this fired, and for which message. Sent back - // under a name of our choosing; nothing had to be registered to receive. - if (AFlutterBridge* Bridge = AFlutterBridge::GetInstance(this)) - { - Bridge->SendToFlutter(TEXT("Scene"), TEXT("saw"), - FString::Printf(TEXT("%s.%s"), *Target, *Method)); + // Answer so the host can see this fired, and for which message. Behind the + // same switch as the bridge's own trace, because echoing every message is + // what you want while proving the path works and noise once it does. + static IConsoleVariable* TraceVar = + IConsoleManager::Get().FindConsoleVariable(TEXT("flutter.TraceMessages")); + if (TraceVar != nullptr && TraceVar->GetInt() != 0) + { + if (AFlutterBridge* Bridge = AFlutterBridge::GetInstance(this)) + { + Bridge->SendToFlutter(TEXT("Scene"), TEXT("saw"), + FString::Printf(TEXT("%s.%s"), *Target, *Method)); + } } // A scene-level command, addressed to whatever you like. No actor is diff --git a/packages/gameframework/ios/Classes/Core/GameEngineController.swift b/packages/gameframework/ios/Classes/Core/GameEngineController.swift index 5553331..7cac870 100644 --- a/packages/gameframework/ios/Classes/Core/GameEngineController.swift +++ b/packages/gameframework/ios/Classes/Core/GameEngineController.swift @@ -168,6 +168,13 @@ open class GameEngineController: NSObject, GameEnginePlatformView, FlutterStream fatalError("unloadEngine() must be overridden") } + /// Undo unloadEngine. Default does nothing, for an engine that cannot. + /// + /// Not abstract like the rest: unloading is a suggestion an engine may or + /// may not be able to act on, so being unable to come back from it is a + /// legitimate answer rather than a missing implementation. + open func reloadEngine() {} + open func destroyEngine() { fatalError("destroyEngine() must be overridden") } @@ -207,6 +214,10 @@ open class GameEngineController: NSObject, GameEnginePlatformView, FlutterStream createEngine() result(true) + case "engine#reload": + reloadEngine() + result(true) + case "engine#isReady": result(isReady) From 08d21c58677d9d344d7589e1bde75b3c26166db8 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Mon, 7 Sep 2026 13:27:20 -0500 Subject: [PATCH 20/25] feat(unreal/macos): give macOS an embedded path, which the engine has none of Unreal has no embedded mode on Mac. UEBuildIOS is the only thing in UnrealBuildTool that defines BUILD_EMBEDDED_APP, and the only embedded view code in the engine is under ApplicationCore/*/IOS. So every one of the nineteen guarded bodies in EmbeddedCommunication.cpp compiled away on Mac: RunOnGameThread dropped the lambda, TickGameThread did nothing, and a host talking to the engine was talking to no-ops that reported success. The Mac target defines it now. It has to reach the engine's own modules and not just this project, because FEmbeddedCommunication lives in Core, so the target takes a unique build environment and the engine rebuilds with it. A source engine allows that and an installed one refuses, the same constraint embedding already has on iOS. FlutterView_Mac.mm then does by hand what the Mac app delegate normally does. There is no StartupEmbeddedUnreal to call, so StartEngine sets a command line and hands GuardedMain to RunGameThread exactly as LaunchMac does. CreateView waits for the engine to build its own FCocoaWindow, borrows that window's content view, and orders the window out so it does not sit on screen empty beside the Flutter one. Borrowing rather than building. The engine already made a view with its Metal layer configured the way it wants, and taking that is far less likely to be wrong than assembling a second one next to it. Sizes are points here, not pixels: AppKit scales for the backing store itself, and multiplying by the scale factor again would render four times the area on any Retina display. The Mac dylib exports all twenty five bridge symbols now, CreateView and StartEngine among them, which it could not do at all before. Two things this does not yet do. Nothing has run: the pieces that would let Flutter show it do not exist on Mac yet, which is the next commit. And the macOS bridge suite's display link check now fails wherever the display is asleep, since CVDisplayLink does not fire then. That is the test depending on the machine rather than on the code, and worth fixing on its own terms. --- .../Private/FlutterBridge_Apple.cpp | 28 +-- .../Private/Mac/FlutterView_Mac.mm | 191 ++++++++++++++++++ .../Private/FlutterBridge_Apple.cpp | 28 +-- .../Private/Mac/FlutterView_Mac.mm | 191 ++++++++++++++++++ .../Source/GameFrameworkProject.Target.cs | 16 ++ 5 files changed, 402 insertions(+), 52 deletions(-) create mode 100644 engines/unreal/plugin/Source/FlutterPlugin/Private/Mac/FlutterView_Mac.mm create mode 100644 example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/Mac/FlutterView_Mac.mm diff --git a/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp b/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp index f10c4a4..244d4e6 100644 --- a/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp +++ b/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp @@ -489,32 +489,8 @@ int32_t UnrealBridge_GetQualitySettings(int32_t* OutValues, int32_t Capacity) return UNREALBRIDGE_QUALITY_VALUE_COUNT; } -#if PLATFORM_MAC - -// macOS has no embedded render path. bShouldCompileAsDLL does not define -// BUILD_EMBEDDED_APP there and no Mac runtime code honours it, so there is no -// engine-owned view to hand over. These exist so the ABI is the same shape on -// both platforms and a host can call them unconditionally. - -void* UnrealBridge_CreateView(float, float, float) -{ - UE_LOG(LogTemp, Warning, - TEXT("[FlutterBridge_Apple] Unreal has no embedded render view on macOS")); - return nullptr; -} - -int32_t UnrealBridge_StartEngine(void) -{ - UE_LOG(LogTemp, Warning, - TEXT("[FlutterBridge_Apple] Unreal has no embedded start path on macOS")); - return 0; -} - -void UnrealBridge_ResizeView(float, float, float) {} -void UnrealBridge_DestroyView(void) {} -int32_t UnrealBridge_IsViewReady(void) { return 0; } - -#endif // PLATFORM_MAC +// The macOS view and engine startup live in Mac/FlutterView_Mac.mm, the same +// way the iOS ones live in IOS/FlutterView_IOS.mm. void UnrealBridge_SetEngineReadyCallback(UnrealEngineReadyCallback Callback) { diff --git a/engines/unreal/plugin/Source/FlutterPlugin/Private/Mac/FlutterView_Mac.mm b/engines/unreal/plugin/Source/FlutterPlugin/Private/Mac/FlutterView_Mac.mm new file mode 100644 index 0000000..562958b --- /dev/null +++ b/engines/unreal/plugin/Source/FlutterPlugin/Private/Mac/FlutterView_Mac.mm @@ -0,0 +1,191 @@ +// Starting Unreal, and getting a view out of it, on macOS. +// +// None of this mirrors the iOS path, because the engine does not offer the same +// thing twice. On iOS Epic wrote an embedded mode: FIOSView, a delegate that +// caches itself, and StartupEmbeddedUnreal to boot the lot. On Mac there is no +// embedded anything. BUILD_EMBEDDED_APP is defined only by UEBuildIOS, and the +// only embedded view code in the engine sits under ApplicationCore/*/IOS. +// +// So this does by hand what the Mac app delegate normally does. It starts the +// game thread the same way LaunchMac does, then waits for the engine to make +// its own window and lends the host that window's view. The engine still +// believes it owns a window; it simply never gets shown. + +#include "CoreMinimal.h" + +#if PLATFORM_MAC + +#include "UnrealBridge.h" + +#include "Mac/CocoaThread.h" +#include "Mac/CocoaWindow.h" +#include "Misc/CommandLine.h" + +#import + +#include + +/// The engine's entry point, as LaunchMac declares it. +extern int32 GuardedMain(const TCHAR* CmdLine); + +namespace +{ + std::atomic GEngineStartRequested{false}; + + /// The view lent to the host, and the window it came from. + NSView* GEmbeddedView = nil; + FCocoaWindow* GEngineWindow = nil; + + /// The command line handed to GuardedMain. + /// + /// Mac keeps its own in LaunchMac.cpp as a file-static, so there is nothing + /// to share, and the engine takes it as an argument anyway. Held here rather + /// than on the stack because the game thread reads it after this returns. + FString GEmbeddedCommandLine; + + /// Find the window the engine made for itself. + /// + /// It arrives some time after the game thread starts, so this returns nil + /// until it does and the host keeps asking, the same as on iOS. + FCocoaWindow* FindEngineWindow() + { + for (NSWindow* Window in [NSApp windows]) + { + if ([Window isKindOfClass:[FCocoaWindow class]]) + { + return (FCocoaWindow*)Window; + } + } + return nil; + } +} + +/// Runs GuardedMain, so the game thread has something to call. +@interface FlutterUnrealLauncher : NSObject +- (void)runGameThread:(id)Argument; +@end + +@implementation FlutterUnrealLauncher +- (void)runGameThread:(id)Argument +{ + GuardedMain(*GEmbeddedCommandLine); +} +@end + +extern "C" { + +int32_t UnrealBridge_StartEngine(void) +{ + if (GEngineStartRequested.exchange(true)) + { + return 1; + } + + // The command line normally comes from argv. There is no argv in a library, + // so the engine gets an empty one and reads the rest from its own config. + GEmbeddedCommandLine = TEXT(""); + + // Start the game thread the way LaunchMac does. RunGameThread registers the + // calling thread as the main one and puts GuardedMain on a thread of its + // own, which is what every later assumption about game and main threads + // depends on. + static FlutterUnrealLauncher* Launcher = [[FlutterUnrealLauncher alloc] init]; + RunGameThread(Launcher, @selector(runGameThread:)); + + UE_LOG(LogTemp, Log, TEXT("[FlutterView_Mac] Engine start requested")); + return 1; +} + +void* UnrealBridge_CreateView(float Width, float Height, float Scale) +{ + if (![NSThread isMainThread]) + { + UE_LOG(LogTemp, Error, + TEXT("[FlutterView_Mac] UnrealBridge_CreateView must be called on the main thread")); + return nullptr; + } + + if (!GEngineStartRequested.load()) + { + return nullptr; + } + + if (GEmbeddedView != nil) + { + UnrealBridge_ResizeView(Width, Height, Scale); + return (void*)GEmbeddedView; + } + + FCocoaWindow* Window = FindEngineWindow(); + if (Window == nil) + { + // Still starting. The host asks again next frame. + return nullptr; + } + + NSView* Content = [Window contentView]; + if (Content == nil) + { + return nullptr; + } + + // Borrow the view rather than build one. The engine already made a window + // with a Metal layer set up the way it wants, and taking that view is far + // less likely to be wrong than assembling a second one beside it. Reparented + // into the host's hierarchy, it renders where Flutter puts it. + GEngineWindow = Window; + GEmbeddedView = [Content retain]; + + // The window it came from would otherwise sit on screen, empty, next to the + // Flutter one. + [Window orderOut:nil]; + + UnrealBridge_ResizeView(Width, Height, Scale); + + UE_LOG(LogTemp, Log, TEXT("[FlutterView_Mac] Lent the engine's view at %.0fx%.0f"), Width, Height); + return (void*)GEmbeddedView; +} + +void UnrealBridge_ResizeView(float Width, float Height, float Scale) +{ + if (GEmbeddedView == nil || Width <= 0.0f || Height <= 0.0f) + { + return; + } + + // Points here, unlike iOS. AppKit scales for the backing store itself, and + // multiplying by the scale factor a second time would render at four times + // the area on any Retina display. + [GEmbeddedView setFrame:NSMakeRect(0.0, 0.0, Width, Height)]; +} + +void UnrealBridge_DestroyView(void) +{ + if (GEmbeddedView == nil) + { + return; + } + + // Hand it back to the window that owns it. Releasing it while the engine + // still holds a viewport pointing at it would leave the renderer drawing + // into freed memory. + if (GEngineWindow != nil) + { + [GEngineWindow setContentView:GEmbeddedView]; + } + + [GEmbeddedView release]; + GEmbeddedView = nil; + GEngineWindow = nil; + + UE_LOG(LogTemp, Log, TEXT("[FlutterView_Mac] Returned the engine's view")); +} + +int32_t UnrealBridge_IsViewReady(void) +{ + return GEmbeddedView != nil ? 1 : 0; +} + +} // extern "C" + +#endif // PLATFORM_MAC diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp index f10c4a4..244d4e6 100644 --- a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp @@ -489,32 +489,8 @@ int32_t UnrealBridge_GetQualitySettings(int32_t* OutValues, int32_t Capacity) return UNREALBRIDGE_QUALITY_VALUE_COUNT; } -#if PLATFORM_MAC - -// macOS has no embedded render path. bShouldCompileAsDLL does not define -// BUILD_EMBEDDED_APP there and no Mac runtime code honours it, so there is no -// engine-owned view to hand over. These exist so the ABI is the same shape on -// both platforms and a host can call them unconditionally. - -void* UnrealBridge_CreateView(float, float, float) -{ - UE_LOG(LogTemp, Warning, - TEXT("[FlutterBridge_Apple] Unreal has no embedded render view on macOS")); - return nullptr; -} - -int32_t UnrealBridge_StartEngine(void) -{ - UE_LOG(LogTemp, Warning, - TEXT("[FlutterBridge_Apple] Unreal has no embedded start path on macOS")); - return 0; -} - -void UnrealBridge_ResizeView(float, float, float) {} -void UnrealBridge_DestroyView(void) {} -int32_t UnrealBridge_IsViewReady(void) { return 0; } - -#endif // PLATFORM_MAC +// The macOS view and engine startup live in Mac/FlutterView_Mac.mm, the same +// way the iOS ones live in IOS/FlutterView_IOS.mm. void UnrealBridge_SetEngineReadyCallback(UnrealEngineReadyCallback Callback) { diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/Mac/FlutterView_Mac.mm b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/Mac/FlutterView_Mac.mm new file mode 100644 index 0000000..562958b --- /dev/null +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/Mac/FlutterView_Mac.mm @@ -0,0 +1,191 @@ +// Starting Unreal, and getting a view out of it, on macOS. +// +// None of this mirrors the iOS path, because the engine does not offer the same +// thing twice. On iOS Epic wrote an embedded mode: FIOSView, a delegate that +// caches itself, and StartupEmbeddedUnreal to boot the lot. On Mac there is no +// embedded anything. BUILD_EMBEDDED_APP is defined only by UEBuildIOS, and the +// only embedded view code in the engine sits under ApplicationCore/*/IOS. +// +// So this does by hand what the Mac app delegate normally does. It starts the +// game thread the same way LaunchMac does, then waits for the engine to make +// its own window and lends the host that window's view. The engine still +// believes it owns a window; it simply never gets shown. + +#include "CoreMinimal.h" + +#if PLATFORM_MAC + +#include "UnrealBridge.h" + +#include "Mac/CocoaThread.h" +#include "Mac/CocoaWindow.h" +#include "Misc/CommandLine.h" + +#import + +#include + +/// The engine's entry point, as LaunchMac declares it. +extern int32 GuardedMain(const TCHAR* CmdLine); + +namespace +{ + std::atomic GEngineStartRequested{false}; + + /// The view lent to the host, and the window it came from. + NSView* GEmbeddedView = nil; + FCocoaWindow* GEngineWindow = nil; + + /// The command line handed to GuardedMain. + /// + /// Mac keeps its own in LaunchMac.cpp as a file-static, so there is nothing + /// to share, and the engine takes it as an argument anyway. Held here rather + /// than on the stack because the game thread reads it after this returns. + FString GEmbeddedCommandLine; + + /// Find the window the engine made for itself. + /// + /// It arrives some time after the game thread starts, so this returns nil + /// until it does and the host keeps asking, the same as on iOS. + FCocoaWindow* FindEngineWindow() + { + for (NSWindow* Window in [NSApp windows]) + { + if ([Window isKindOfClass:[FCocoaWindow class]]) + { + return (FCocoaWindow*)Window; + } + } + return nil; + } +} + +/// Runs GuardedMain, so the game thread has something to call. +@interface FlutterUnrealLauncher : NSObject +- (void)runGameThread:(id)Argument; +@end + +@implementation FlutterUnrealLauncher +- (void)runGameThread:(id)Argument +{ + GuardedMain(*GEmbeddedCommandLine); +} +@end + +extern "C" { + +int32_t UnrealBridge_StartEngine(void) +{ + if (GEngineStartRequested.exchange(true)) + { + return 1; + } + + // The command line normally comes from argv. There is no argv in a library, + // so the engine gets an empty one and reads the rest from its own config. + GEmbeddedCommandLine = TEXT(""); + + // Start the game thread the way LaunchMac does. RunGameThread registers the + // calling thread as the main one and puts GuardedMain on a thread of its + // own, which is what every later assumption about game and main threads + // depends on. + static FlutterUnrealLauncher* Launcher = [[FlutterUnrealLauncher alloc] init]; + RunGameThread(Launcher, @selector(runGameThread:)); + + UE_LOG(LogTemp, Log, TEXT("[FlutterView_Mac] Engine start requested")); + return 1; +} + +void* UnrealBridge_CreateView(float Width, float Height, float Scale) +{ + if (![NSThread isMainThread]) + { + UE_LOG(LogTemp, Error, + TEXT("[FlutterView_Mac] UnrealBridge_CreateView must be called on the main thread")); + return nullptr; + } + + if (!GEngineStartRequested.load()) + { + return nullptr; + } + + if (GEmbeddedView != nil) + { + UnrealBridge_ResizeView(Width, Height, Scale); + return (void*)GEmbeddedView; + } + + FCocoaWindow* Window = FindEngineWindow(); + if (Window == nil) + { + // Still starting. The host asks again next frame. + return nullptr; + } + + NSView* Content = [Window contentView]; + if (Content == nil) + { + return nullptr; + } + + // Borrow the view rather than build one. The engine already made a window + // with a Metal layer set up the way it wants, and taking that view is far + // less likely to be wrong than assembling a second one beside it. Reparented + // into the host's hierarchy, it renders where Flutter puts it. + GEngineWindow = Window; + GEmbeddedView = [Content retain]; + + // The window it came from would otherwise sit on screen, empty, next to the + // Flutter one. + [Window orderOut:nil]; + + UnrealBridge_ResizeView(Width, Height, Scale); + + UE_LOG(LogTemp, Log, TEXT("[FlutterView_Mac] Lent the engine's view at %.0fx%.0f"), Width, Height); + return (void*)GEmbeddedView; +} + +void UnrealBridge_ResizeView(float Width, float Height, float Scale) +{ + if (GEmbeddedView == nil || Width <= 0.0f || Height <= 0.0f) + { + return; + } + + // Points here, unlike iOS. AppKit scales for the backing store itself, and + // multiplying by the scale factor a second time would render at four times + // the area on any Retina display. + [GEmbeddedView setFrame:NSMakeRect(0.0, 0.0, Width, Height)]; +} + +void UnrealBridge_DestroyView(void) +{ + if (GEmbeddedView == nil) + { + return; + } + + // Hand it back to the window that owns it. Releasing it while the engine + // still holds a viewport pointing at it would leave the renderer drawing + // into freed memory. + if (GEngineWindow != nil) + { + [GEngineWindow setContentView:GEmbeddedView]; + } + + [GEmbeddedView release]; + GEmbeddedView = nil; + GEngineWindow = nil; + + UE_LOG(LogTemp, Log, TEXT("[FlutterView_Mac] Returned the engine's view")); +} + +int32_t UnrealBridge_IsViewReady(void) +{ + return GEmbeddedView != nil ? 1 : 0; +} + +} // extern "C" + +#endif // PLATFORM_MAC diff --git a/example/unreal/demo/Source/GameFrameworkProject.Target.cs b/example/unreal/demo/Source/GameFrameworkProject.Target.cs index 8ba8a1f..810ec8f 100644 --- a/example/unreal/demo/Source/GameFrameworkProject.Target.cs +++ b/example/unreal/demo/Source/GameFrameworkProject.Target.cs @@ -40,6 +40,22 @@ public GameFrameworkProjectTarget(TargetInfo Target) : base(Target) { LinkType = TargetLinkType.Monolithic; bShouldCompileAsDLL = true; + + // Turn on the embedded path for Mac, which the engine never does + // itself. UEBuildIOS adds BUILD_EMBEDDED_APP for iOS and nothing in + // UnrealBuildTool adds it anywhere else, so on Mac every body in + // EmbeddedCommunication.cpp compiles away: RunOnGameThread drops + // the lambda, TickGameThread does nothing, and a host talking to + // the engine is talking to no-ops. + // + // It has to reach the engine's own modules, not just this project. + // FEmbeddedCommunication lives in Core, so a definition that stops + // at the project changes nothing at all, and a unique build + // environment is what makes the engine rebuild with it. A source + // engine allows that; an installed one refuses, which is the same + // constraint embedding has on iOS. + BuildEnvironment = TargetBuildEnvironment.Unique; + GlobalDefinitions.Add("BUILD_EMBEDDED_APP=1"); } if (Target.Platform == UnrealTargetPlatform.IOS) From 1c3aae6403674b0a8f108b4cff14b3d12bbf4d57 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Mon, 7 Sep 2026 13:43:29 -0500 Subject: [PATCH 21/25] feat(macos): build the macOS half of the framework GameWidget had no macOS path at all. packages/gameframework/macos was a single file registering a method channel: no controller, no registry, no platform view factory, no container view. The Dart side has branched to AppKitView for a while and nothing native ever answered it. So macOS now has the same three pieces iOS does, answering the same method channel and raising the same events, because the Dart side should not need to know which platform it is talking to. What differs is only what AppKit forces. The container lays its subview out by hand, since NSView does not resize children the way autoresizing hints suggest. The factory takes a view identifier and no frame, so the controller starts at zero and is sized on the first layout pass, which is where iOS ends up anyway. And registration is registerViewFactory rather than register. Unreal's macOS controller was a standalone NSObject with its own unrelated API, wired to nothing. It subclasses the shared controller now and reaches the bridge directly rather than through the Objective-C runtime: the bridge is pod code and always present here, where on iOS it has to tolerate the engine framework being absent. The bridge itself gained the view path, which it never had: the engine builds its own window some time after the game thread starts, so the tick asks for a view until one exists and hands it to the controller, the same shape as iOS. Sizes are points, not pixels. AppKit scales for the backing store itself and multiplying by the scale factor again would render four times the area on any Retina display. Pause, unload and reload came along with it. Starting the engine can wait for a widget here. There is no app delegate reading the command line the moment the app becomes active, which on iOS is what forces the engine up during didFinishLaunching. Nothing has run yet. The Mac framework still has to be packaged and synced before a Flutter app can load it. --- .../unreal/dart/macos/Classes/UnrealBridge.h | 9 + .../unreal/dart/macos/Classes/UnrealBridge.mm | 102 +++- .../Classes/UnrealEngineController.swift | 543 +++++------------- .../macos/Classes/UnrealEnginePlugin.swift | 349 +---------- .../Classes/Core/GameEngineController.swift | 283 +++++++++ .../Classes/Core/GameEngineRegistry.swift | 116 ++++ .../macos/Classes/GameframeworkPlugin.swift | 26 + 7 files changed, 693 insertions(+), 735 deletions(-) create mode 100644 packages/gameframework/macos/Classes/Core/GameEngineController.swift create mode 100644 packages/gameframework/macos/Classes/Core/GameEngineRegistry.swift diff --git a/engines/unreal/dart/macos/Classes/UnrealBridge.h b/engines/unreal/dart/macos/Classes/UnrealBridge.h index ade90eb..860432e 100644 --- a/engines/unreal/dart/macos/Classes/UnrealBridge.h +++ b/engines/unreal/dart/macos/Classes/UnrealBridge.h @@ -33,6 +33,15 @@ NS_ASSUME_NONNULL_BEGIN /// on the interface because the controller's view plumbing expects it. - (nullable NSView *)getView; +/// Tell the engine the size it is rendering at, in points. +- (void)resizeViewTo:(CGSize)size NS_SWIFT_NAME(resizeView(to:)); + +/// Give the engine's view back, freeing what it holds. Reversible. +- (void)destroyView; + +/// Take a view again after destroyView. +- (void)restoreView; + - (void)pause; - (void)resume; - (void)quit; diff --git a/engines/unreal/dart/macos/Classes/UnrealBridge.mm b/engines/unreal/dart/macos/Classes/UnrealBridge.mm index 62ee5fa..7712130 100644 --- a/engines/unreal/dart/macos/Classes/UnrealBridge.mm +++ b/engines/unreal/dart/macos/Classes/UnrealBridge.mm @@ -7,6 +7,7 @@ #import #import +#import #import #import #import "UnrealBridge.h" @@ -56,6 +57,10 @@ typedef void (*ApplyQualitySettingsFn)(int32_t, int32_t, int32_t, int32_t, typedef int32_t (*TickFn)(float); typedef void (*KeepAwakeFn)(const char*, int32_t); typedef void (*AllowSleepFn)(const char*); +typedef int32_t (*StartEngineFn)(void); +typedef void* (*CreateViewFn)(float, float, float); +typedef void (*ResizeViewFn)(float, float, float); +typedef void (*DestroyViewFn)(void); typedef void (*PauseFn)(int32_t); typedef void (*StopFn)(void); typedef int32_t (*IsReadyFn)(void); @@ -90,6 +95,9 @@ static BOOL UnrealFrameworkLinked(void) { // The Swift controller. Held strongly for as long as the bridge is live. static id GUnrealEngineController = nil; +/// The size the host last asked for, in points. +static CGSize GRequestedViewSize = {1280.0, 720.0}; + // ============================================================ // MARK: - Callbacks from Unreal // ============================================================ @@ -197,13 +205,58 @@ static void HandleUnrealBinary(const char* target, const char* method, // Ticking happens on the main thread. That is where the host lives, and where // an embedded engine expects to be driven from. +/// The engine's view, once it has lent us one, and whether the host wants it. +/// +/// The engine builds its own window some time after the game thread starts, so +/// there is nothing to take at first. The tick keeps asking, which is also the +/// only workable moment: too early and there is no window, and the engine has +/// no readiness signal a plugin can subscribe to in time. +static NSView* GEngineView = nil; +static BOOL GViewSuppressed = NO; + +static void OfferViewToController(void); + static void UnrealTick(double deltaSeconds) { + if (!GViewSuppressed && GEngineView == nil) { + OfferViewToController(); + } + TickFn tick = UNREAL_FN(TickFn, "UnrealBridge_Tick"); if (tick) { tick((float)deltaSeconds); } } +/// Ask the engine for its view, and hand it to the controller once it has one. +static void OfferViewToController(void) { + CreateViewFn createView = UNREAL_FN(CreateViewFn, "UnrealBridge_CreateView"); + if (!createView) { + return; + } + + // Points. AppKit scales for the backing store itself, so the size the host + // asked for is the size the engine is told about. + const CGSize size = GRequestedViewSize; + void* handle = createView((float)size.width, (float)size.height, 1.0f); + if (!handle) { + // Still starting. Asked again next frame. + return; + } + + GEngineView = (__bridge NSView*)handle; + NSLog(@"[UnrealBridge] Render view arrived at %@", NSStringFromSize(size)); + + id controller = GUnrealEngineController; + SEL selector = NSSelectorFromString(@"onUnrealViewReady:"); + if ([controller respondsToSelector:selector]) { + ((void (*)(id, SEL, NSView*))objc_msgSend)(controller, selector, GEngineView); + } else { + // Expected when the engine starts before any widget exists. The + // controller collects it from getView when one turns up. + NSLog(@"[UnrealBridge] Render view arrived before a controller existed"); + } +} + /// Two display link APIs, picked at runtime. /// /// NSScreen.displayLink arrived in macOS 14 and CVDisplayLink is deprecated @@ -336,6 +389,21 @@ - (BOOL)createWithConfig:(NSDictionary*)config controller:(id)controller { @"Place one in your level if messages never arrive."); } + // Start the engine. Nothing renders until this runs: on macOS it puts + // GuardedMain on a game thread, which is what LaunchMac would have done if + // this were an app rather than a library. + // + // Unlike iOS this can wait until a widget exists. There is no app delegate + // reading the command line the moment the app becomes active, so nothing + // demands the engine be up before then. + StartEngineFn startEngine = UNREAL_FN(StartEngineFn, "UnrealBridge_StartEngine"); + if (startEngine) { + NSLog(@"[UnrealBridge] StartEngine -> %d", startEngine()); + } + + KeepAwakeFn keepAwake = UNREAL_FN(KeepAwakeFn, "UnrealBridge_KeepAwake"); + if (keepAwake) keepAwake("flutter", 1); + InitFn initEngine = UNREAL_FN(InitFn, "UnrealBridge_Init"); if (initEngine) initEngine(); @@ -346,8 +414,38 @@ - (BOOL)createWithConfig:(NSDictionary*)config controller:(id)controller { } - (NSView*)getView { - // Unreal owns its own window on macOS; there is no subview to hand back. - return nil; + return GEngineView; +} + +- (void)resizeViewTo:(CGSize)size { + if (size.width <= 0.0 || size.height <= 0.0) { + return; + } + + GRequestedViewSize = size; + + ResizeViewFn resize = UNREAL_FN(ResizeViewFn, "UnrealBridge_ResizeView"); + if (resize) resize((float)size.width, (float)size.height, 1.0f); +} + +- (void)destroyView { + GViewSuppressed = YES; + + DestroyViewFn destroyView = UNREAL_FN(DestroyViewFn, "UnrealBridge_DestroyView"); + if (destroyView) destroyView(); + + GEngineView = nil; + NSLog(@"[UnrealBridge] Render view released"); +} + +- (void)restoreView { + if (!GViewSuppressed) { + return; + } + + // The tick offers a view again from here, the same way it did at startup. + GViewSuppressed = NO; + NSLog(@"[UnrealBridge] Render view will be rebuilt"); } - (void)pause { diff --git a/engines/unreal/dart/macos/Classes/UnrealEngineController.swift b/engines/unreal/dart/macos/Classes/UnrealEngineController.swift index b34d481..2b84e5b 100644 --- a/engines/unreal/dart/macos/Classes/UnrealEngineController.swift +++ b/engines/unreal/dart/macos/Classes/UnrealEngineController.swift @@ -1,462 +1,217 @@ import Cocoa import FlutterMacOS +import gameframework /** - * Unreal Engine Controller for macOS + * Unreal Engine controller for macOS. * - * Manages the Unreal Engine lifecycle, view integration, and communication - * between Flutter and Unreal Engine on macOS. + * Subclasses the shared GameEngineController, so it answers the same method + * channel and raises the same events as the iOS one and the Dart side does not + * need to know which it is talking to. + * + * Unlike iOS this reaches the bridge directly rather than through the + * Objective-C runtime. The bridge is pod code and always present here; on iOS + * it has to tolerate the engine framework being missing entirely. */ -public class UnrealEngineController: NSObject { - - // MARK: - Properties +public class UnrealEngineController: GameEngineController { - private let viewId: Int - private let channel: FlutterMethodChannel - private let config: [String: Any] + public static let engineTypeValue = "unreal" + public static let engineVersionValue = "5.8" private var unrealView: NSView? - private var unrealFramework: UnrealFramework? - - private var isReady: Bool = false - private var isPaused: Bool = false - private var isDestroyed: Bool = false - - // MARK: - Constants - - private static let engineType = "unreal" - private static let engineVersion = "5.3.0" - - // MARK: - Initialization - - public init(viewId: Int, channel: FlutterMethodChannel, config: [String: Any]) { - self.viewId = viewId - self.channel = channel - self.config = config - super.init() - } - - // MARK: - Lifecycle Methods - - /** - * Initialize and create the Unreal Engine instance - */ - public func create() -> Bool { - if isDestroyed { - sendError("Cannot create destroyed engine") - return false - } - - if isReady { - return true - } - - // Load Unreal Framework - guard let framework = loadUnrealFramework() else { - sendError("Failed to load Unreal Framework") - return false - } - unrealFramework = framework + /// Whether unloadEngine gave the view back. Guards reload, so calling it on + /// a running engine does nothing rather than resuming something that was + /// never paused. + private var isUnloaded = false - // Apply configuration - applyConfiguration(config) + public override var engineType: String { UnrealEngineController.engineTypeValue } + public override var engineVersion: String { UnrealEngineController.engineVersionValue } - // Initialize Unreal Engine - if !nativeCreate(config) { - sendError("Failed to create Unreal Engine instance") - return false - } - - // Get Unreal view - guard let view = nativeGetView() else { - sendError("Failed to get Unreal view") - return false - } - - unrealView = view - isReady = true - - sendEvent("created") - sendEvent("loaded") - - return true - } - - /** - * Pause the Unreal Engine - */ - public func pause() { - if !isReady || isDestroyed { - return - } - - nativePause() - isPaused = true - sendEvent("paused") - } - - /** - * Resume the Unreal Engine - */ - public func resume() { - if !isReady || isDestroyed { - return - } - - nativeResume() - isPaused = false - sendEvent("resumed") - } - - /** - * Unload the Unreal Engine (pause and detach) - */ - public func unload() { - if !isReady || isDestroyed { - return - } - - pause() - sendEvent("unloaded") - } - - /** - * Quit and destroy the Unreal Engine - */ - public func quit() { - if isDestroyed { - return - } - - nativeQuit() - unrealView = nil - unrealFramework = nil - isReady = false - isDestroyed = true - sendEvent("destroyed") - } - - // MARK: - Communication Methods - - /** - * Send a message to Unreal Engine - */ - public func sendMessage(target: String, method: String, data: String) { - if !isReady || isDestroyed { - sendError("Engine not ready for messages") - return - } - - nativeSendMessage(target: target, method: method, data: data) - } - - /** - * Send a JSON message to Unreal Engine - */ - public func sendJsonMessage(target: String, method: String, data: [String: Any]) { - if !isReady || isDestroyed { - sendError("Engine not ready for messages") - return - } - - guard let jsonData = try? JSONSerialization.data(withJSONObject: data, options: []), - let jsonString = String(data: jsonData, encoding: .utf8) else { - sendError("Failed to serialize JSON message") - return - } - - nativeSendMessage(target: target, method: method, data: jsonString) - } + public override func createEngine() { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } - // MARK: - Unreal-Specific Methods + NSLog("UnrealEngineController: Creating Unreal Engine...") - /** - * Execute a console command in Unreal Engine - */ - public func executeConsoleCommand(_ command: String) { - if !isReady || isDestroyed { - sendError("Engine not ready for console commands") - return - } + let bridge = UnrealBridge.shared + let config: [String: Any] = self.getConfigValue("config") ?? [:] - nativeExecuteConsoleCommand(command) - } - - /** - * Load a level/map in Unreal Engine - */ - public func loadLevel(_ levelName: String) { - if !isReady || isDestroyed { - sendError("Engine not ready to load level") - return - } + guard bridge.create(config: config, controller: self) else { + NSLog("UnrealEngineController: Failed to create Unreal Engine") + self.sendEvent(name: "onError", data: [ + "message": "Failed to create the Unreal engine. Is UnrealFramework linked?" + ]) + // Ready anyway, so the app can show the error rather than wait + // forever for a state that is never coming. + self._isReady = true + return + } - nativeLoadLevel(levelName) - } + if let view = bridge.getView() { + self.attach(view) + } else { + // Normal on the first call. The engine hands its view over once + // it has built one, and the bridge offers it from the tick. + NSLog("UnrealEngineController: Waiting for the engine's render view") + } - /** - * Apply quality settings to Unreal Engine - */ - public func applyQualitySettings(_ settings: [String: Any]) { - if !isReady || isDestroyed { - sendError("Engine not ready for quality settings") - return + self._isReady = true + self.sendEvent(name: "onCreated", data: nil) + self.sendEvent(name: "onLoaded", data: nil) + self.sendEvent(name: "onMessage", data: [ + "target": "Unreal", + "method": "onReady", + "data": "{\"success\":true,\"message\":\"Unreal Engine ready\"}" + ]) } - - nativeApplyQualitySettings(settings) } - /** - * Get current quality settings from Unreal Engine - */ - public func getQualitySettings() -> [String: Any]? { - if !isReady || isDestroyed { - sendError("Engine not ready") - return nil + /// Called from the bridge once the engine's view exists. + @objc public func onUnrealViewReady(_ view: NSView) { + DispatchQueue.main.async { [weak self] in + self?.attach(view) } - - return nativeGetQualitySettings() - } - - /** - * Check if engine is in background - */ - public func isInBackground() -> Bool { - return isPaused - } - - // MARK: - View Integration - - /** - * Get the Unreal Engine view to attach to Flutter - */ - public func getView() -> NSView? { - return unrealView } - /** - * Attach Unreal view to parent - */ - public func attachView(to parent: NSView) { - guard let view = unrealView, view.superview == nil else { - return - } - - view.frame = parent.bounds - view.autoresizingMask = [.width, .height] - parent.addSubview(view) - sendEvent("attached") + private func attach(_ view: NSView) { + NSLog("UnrealEngineController: Unreal render view arrived") + unrealView = view + addEngineView(view) + engineViewDidResize(to: self.view().bounds.size) + sendEvent(name: "onAttached", data: nil) } - /** - * Detach Unreal view from parent - */ - public func detachView() { - guard let view = unrealView else { - return + public override func attachEngine() { + DispatchQueue.main.async { [weak self] in + guard let self = self, let view = self.unrealView else { return } + self.addEngineView(view) } - - view.removeFromSuperview() - sendEvent("detached") } - // MARK: - Event Handling - - private func sendEvent(_ eventType: String, message: String? = nil) { + public override func detachEngine() { DispatchQueue.main.async { [weak self] in guard let self = self else { return } - - var arguments: [String: Any] = ["type": eventType] - if let message = message { - arguments["message"] = message - } - - self.channel.invokeMethod("onEvent", arguments: arguments) + self.removeEngineView() + self.sendEvent(name: "onDetached", data: nil) } } - private func sendError(_ message: String) { - NSLog("[UnrealEngineController] Error: \(message)") - sendEvent("error", message: message) + /// Push the current container size down to the engine. + /// + /// Points, not pixels. AppKit scales for the backing store itself, and + /// multiplying by the scale factor again would render four times the area + /// on any Retina display. + public override func engineViewDidResize(to size: CGSize) { + guard size.width > 0, size.height > 0 else { return } + UnrealBridge.shared.resizeView(to: size) } - /** - * Called from native code when a message is received from Unreal - */ - @objc public func onMessageFromUnreal(target: String, method: String, data: String) { + public override func pauseEngine() { DispatchQueue.main.async { [weak self] in guard let self = self else { return } - - self.channel.invokeMethod("onMessage", arguments: [ - "target": target, - "method": method, - "data": data - ]) + UnrealBridge.shared.pause() + self._isPaused = true + self.sendEvent(name: "onPaused", data: nil) } } - /** - * Called from native code when a level is loaded - */ - @objc public func onLevelLoaded(levelName: String, buildIndex: Int) { + public override func resumeEngine() { DispatchQueue.main.async { [weak self] in guard let self = self else { return } - - self.channel.invokeMethod("onLevelLoaded", arguments: [ - "name": levelName, - "buildIndex": buildIndex, - "isLoaded": true, - "isValid": true, - "metadata": [String: Any]() - ]) + UnrealBridge.shared.resume() + self._isPaused = false + self.sendEvent(name: "onResumed", data: nil) } } - // MARK: - Framework Loading + /// Give back everything an idle engine is holding, short of tearing it down. + /// + /// Unreal cannot be unloaded and started again in one process, so this is + /// not a teardown. The game pauses and the render view goes away, which is + /// the expensive part. Reversible through reloadEngine. + public override func unloadEngine() { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } - private func loadUnrealFramework() -> UnrealFramework? { - // Load Unreal Framework from app bundle - let bundlePath = Bundle.main.bundlePath + "/Contents/Frameworks/UnrealFramework.framework" + NSLog("UnrealEngineController: Unloading Unreal (pausing, releasing the view)") + UnrealBridge.shared.pause() + UnrealBridge.shared.destroyView() - guard let bundle = Bundle(path: bundlePath) else { - NSLog("[UnrealEngineController] Failed to find UnrealFramework bundle at: \(bundlePath)") - return nil - } + self.removeEngineView() + self.unrealView = nil + self._isPaused = true + self.isUnloaded = true - guard bundle.load() else { - NSLog("[UnrealEngineController] Failed to load UnrealFramework bundle") - return nil + self.sendEvent(name: "onUnloaded", data: nil) } - - NSLog("[UnrealEngineController] Successfully loaded UnrealFramework") - return UnrealFramework(bundle: bundle) } - private func applyConfiguration(_ config: [String: Any]) { - // Apply configuration to Unreal Engine - // This can include graphics settings, game mode, etc. - if let enableMetal = config["enableMetal"] as? Bool, enableMetal { - // Enable Metal graphics API (default on macOS) - NSLog("[UnrealEngineController] Metal graphics enabled") - } + public override func reloadEngine() { + DispatchQueue.main.async { [weak self] in + guard let self = self, self.isUnloaded else { return } - if let enableHighDPI = config["enableHighDPI"] as? Bool, enableHighDPI { - // Enable high DPI rendering (Retina support) - NSLog("[UnrealEngineController] High DPI rendering enabled") - } + NSLog("UnrealEngineController: Reloading Unreal") + self.isUnloaded = false + UnrealBridge.shared.restoreView() + UnrealBridge.shared.resume() - if let enableFullscreen = config["enableFullscreen"] as? Bool, enableFullscreen { - NSLog("[UnrealEngineController] Fullscreen mode enabled") + self._isPaused = false + self.sendEvent(name: "onLoaded", data: nil) } } - // MARK: - Native Bridge Methods (Objective-C++) - - /** - * Create Unreal Engine instance - * Implemented in Objective-C++ bridge - */ - private func nativeCreate(_ config: [String: Any]) -> Bool { - // This will call into Objective-C++ bridge -> Unreal C++ - return UnrealBridge.shared.create(config: config, controller: self) - } - - /** - * Get the native Unreal view - * Implemented in Objective-C++ bridge - */ - private func nativeGetView() -> NSView? { - return UnrealBridge.shared.getView() - } - - /** - * Pause the engine - * Implemented in Objective-C++ bridge - */ - private func nativePause() { - UnrealBridge.shared.pause() - } - - /** - * Resume the engine - * Implemented in Objective-C++ bridge - */ - private func nativeResume() { - UnrealBridge.shared.resume() - } - - /** - * Quit the engine - * Implemented in Objective-C++ bridge - */ - private func nativeQuit() { - UnrealBridge.shared.quit() - } - - /** - * Send message to Unreal - * Implemented in Objective-C++ bridge - */ - private func nativeSendMessage(target: String, method: String, data: String) { - UnrealBridge.shared.sendMessage(target: target, method: method, data: data) + public override func destroyEngine() { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + UnrealBridge.shared.quit() + self.removeEngineView() + self.unrealView = nil + self._isReady = false + self.sendEvent(name: "onDestroyed", data: nil) + } } - /** - * Execute console command - * Implemented in Objective-C++ bridge - */ - private func nativeExecuteConsoleCommand(_ command: String) { - UnrealBridge.shared.executeConsoleCommand(command) + public override func sendMessage(target: String, method: String, data: String) { + DispatchQueue.main.async { + NSLog("UnrealEngineController: Sending message - Target: \(target), Method: \(method)") + UnrealBridge.shared.sendMessage(target: target, method: method, data: data) + } } - /** - * Load level - * Implemented in Objective-C++ bridge - */ - private func nativeLoadLevel(_ levelName: String) { - UnrealBridge.shared.loadLevel(levelName) - } + // MARK: - Called from the bridge - /** - * Apply quality settings - * Implemented in Objective-C++ bridge - */ - private func nativeApplyQualitySettings(_ settings: [String: Any]) { - UnrealBridge.shared.applyQualitySettings(settings) + @objc public func onMessageFromUnreal(target: String, method: String, data: String) { + sendEvent(name: "onMessage", data: [ + "target": target, + "method": method, + "data": data + ]) } - /** - * Get quality settings - * Implemented in Objective-C++ bridge - */ - private func nativeGetQualitySettings() -> [String: Any] { - return UnrealBridge.shared.getQualitySettings() + @objc public func onLevelLoaded(levelName: String, buildIndex: Int) { + sendEvent(name: "onSceneLoaded", data: [ + "name": levelName, + "buildIndex": buildIndex, + "isLoaded": true + ]) } } -// MARK: - Unreal Framework Wrapper - -/** - * Wrapper for Unreal Framework bundle - */ -private class UnrealFramework { - let bundle: Bundle - - init(bundle: Bundle) { - self.bundle = bundle +/// Builds controllers for the shared registry. +public class UnrealEngineFactory: NSObject, GameEngineFactory { + public func createController( + frame: CGRect, + viewId: Int64, + messenger: FlutterBinaryMessenger, + config: [String: Any] + ) -> GameEnginePlatformView { + NSLog("UnrealEngineFactory: Creating controller with viewId \(viewId)") + return UnrealEngineController( + frame: frame, + viewId: viewId, + messenger: messenger, + config: config + ) } } - -// MARK: - Unreal Bridge Interface -// -// The UnrealBridge class is implemented in Objective-C++ (UnrealBridge.mm) and -// declared in UnrealBridge.h, which the pod exposes to Swift. It used to be -// duplicated here as a Swift placeholder whose methods all returned false or -// nil; two classes registered under the same Objective-C name, with the winner -// undefined, and the placeholder is the one that made createWithConfig always -// fail. Do not add it back. -// -// Callbacks from Unreal arrive on onMessageFromUnreal(target:method:data:) and -// onLevelLoaded(levelName:buildIndex:) above, dispatched by name from the -// bridge. diff --git a/engines/unreal/dart/macos/Classes/UnrealEnginePlugin.swift b/engines/unreal/dart/macos/Classes/UnrealEnginePlugin.swift index 715357d..89020cb 100644 --- a/engines/unreal/dart/macos/Classes/UnrealEnginePlugin.swift +++ b/engines/unreal/dart/macos/Classes/UnrealEnginePlugin.swift @@ -1,354 +1,25 @@ import Cocoa import FlutterMacOS +import gameframework /** - * Unreal Engine Plugin for macOS - * - * Manages Unreal Engine integration with Flutter on macOS. - * Provides lifecycle management, communication, and quality settings control. + * Registers the Unreal engine with the game framework on macOS. */ public class UnrealEnginePlugin: NSObject, FlutterPlugin { - // MARK: - Properties - - private var channel: FlutterMethodChannel? - private var controllers: [Int: UnrealEngineController] = [:] - - // MARK: - Constants - - private static let channelName = "gameframework_unreal" private static let engineType = "unreal" - private static let engineVersion = "5.3.0" - - // MARK: - Plugin Registration public static func register(with registrar: FlutterPluginRegistrar) { - let channel = FlutterMethodChannel( - name: channelName, - binaryMessenger: registrar.messenger - ) - - let instance = UnrealEnginePlugin() - instance.channel = channel - - registrar.addMethodCallDelegate(instance, channel: channel) - } - - // MARK: - Method Call Handler - - public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - switch call.method { - case "getPlatformVersion": - handleGetPlatformVersion(result: result) - - case "getEngineType": - handleGetEngineType(result: result) - - case "getEngineVersion": - handleGetEngineVersion(result: result) - - case "isEngineSupported": - handleIsEngineSupported(result: result) - - case "engine#create": - handleEngineCreate(call: call, result: result) - - case "engine#pause": - handleEnginePause(call: call, result: result) - - case "engine#resume": - handleEngineResume(call: call, result: result) - - case "engine#unload": - handleEngineUnload(call: call, result: result) - - case "engine#quit": - handleEngineQuit(call: call, result: result) - - case "engine#sendMessage": - handleSendMessage(call: call, result: result) - - case "engine#sendJsonMessage": - handleSendJsonMessage(call: call, result: result) - - case "engine#executeConsoleCommand": - handleExecuteConsoleCommand(call: call, result: result) + NSLog("UnrealEnginePlugin: Registering plugin...") - case "engine#loadLevel": - handleLoadLevel(call: call, result: result) - - case "engine#applyQualitySettings": - handleApplyQualitySettings(call: call, result: result) - - case "engine#getQualitySettings": - handleGetQualitySettings(call: call, result: result) - - case "engine#isInBackground": - handleIsInBackground(call: call, result: result) - - default: - result(FlutterMethodNotImplemented) - } - } - - // MARK: - Platform Info Handlers - - private func handleGetPlatformVersion(result: @escaping FlutterResult) { - let version = ProcessInfo.processInfo.operatingSystemVersion - result("macOS \(version.majorVersion).\(version.minorVersion).\(version.patchVersion)") - } - - private func handleGetEngineType(result: @escaping FlutterResult) { - result(UnrealEnginePlugin.engineType) - } - - private func handleGetEngineVersion(result: @escaping FlutterResult) { - result(UnrealEnginePlugin.engineVersion) - } - - private func handleIsEngineSupported(result: @escaping FlutterResult) { - result(true) - } - - // MARK: - Engine Lifecycle Handlers - - private func handleEngineCreate(call: FlutterMethodCall, result: @escaping FlutterResult) { - guard let args = call.arguments as? [String: Any], - let viewId = args["viewId"] as? Int else { - result(FlutterError( - code: "INVALID_ARGUMENTS", - message: "viewId is required", - details: nil - )) - return - } - - let config = args["config"] as? [String: Any] ?? [:] - - guard let channel = self.channel else { - result(FlutterError( - code: "NO_CHANNEL", - message: "Method channel not available", - details: nil - )) - return - } - - let controller = UnrealEngineController( - viewId: viewId, - channel: channel, - config: config + GameEngineRegistry.shared.registerFactory( + engineType: engineType, + factory: UnrealEngineFactory() ) + NSLog("UnrealEnginePlugin: Registered factory for engine type '\(engineType)'") - let success = controller.create() - if success { - controllers[viewId] = controller - } - - result(success) - } - - private func handleEnginePause(call: FlutterMethodCall, result: @escaping FlutterResult) { - guard let controller = getController(from: call) else { - result(FlutterError( - code: "NO_CONTROLLER", - message: "Controller not found", - details: nil - )) - return - } - - controller.pause() - result(nil) - } - - private func handleEngineResume(call: FlutterMethodCall, result: @escaping FlutterResult) { - guard let controller = getController(from: call) else { - result(FlutterError( - code: "NO_CONTROLLER", - message: "Controller not found", - details: nil - )) - return - } - - controller.resume() - result(nil) - } - - private func handleEngineUnload(call: FlutterMethodCall, result: @escaping FlutterResult) { - guard let controller = getController(from: call) else { - result(FlutterError( - code: "NO_CONTROLLER", - message: "Controller not found", - details: nil - )) - return - } - - controller.unload() - result(nil) - } - - private func handleEngineQuit(call: FlutterMethodCall, result: @escaping FlutterResult) { - guard let args = call.arguments as? [String: Any], - let viewId = args["viewId"] as? Int else { - result(FlutterError( - code: "INVALID_ARGUMENTS", - message: "viewId is required", - details: nil - )) - return - } - - if let controller = controllers[viewId] { - controller.quit() - controllers.removeValue(forKey: viewId) - } - - result(nil) - } - - // MARK: - Communication Handlers - - private func handleSendMessage(call: FlutterMethodCall, result: @escaping FlutterResult) { - guard let controller = getController(from: call), - let args = call.arguments as? [String: Any], - let target = args["target"] as? String, - let method = args["method"] as? String, - let data = args["data"] as? String else { - result(FlutterError( - code: "INVALID_ARGUMENTS", - message: "target, method, and data are required", - details: nil - )) - return - } - - controller.sendMessage(target: target, method: method, data: data) - result(nil) - } - - private func handleSendJsonMessage(call: FlutterMethodCall, result: @escaping FlutterResult) { - guard let controller = getController(from: call), - let args = call.arguments as? [String: Any], - let target = args["target"] as? String, - let method = args["method"] as? String, - let data = args["data"] as? [String: Any] else { - result(FlutterError( - code: "INVALID_ARGUMENTS", - message: "target, method, and data are required", - details: nil - )) - return - } - - controller.sendJsonMessage(target: target, method: method, data: data) - result(nil) - } - - // MARK: - Unreal-Specific Handlers - - private func handleExecuteConsoleCommand(call: FlutterMethodCall, result: @escaping FlutterResult) { - guard let controller = getController(from: call), - let args = call.arguments as? [String: Any], - let command = args["command"] as? String else { - result(FlutterError( - code: "INVALID_ARGUMENTS", - message: "command is required", - details: nil - )) - return - } - - controller.executeConsoleCommand(command) - result(nil) - } - - private func handleLoadLevel(call: FlutterMethodCall, result: @escaping FlutterResult) { - guard let controller = getController(from: call), - let args = call.arguments as? [String: Any], - let levelName = args["levelName"] as? String else { - result(FlutterError( - code: "INVALID_ARGUMENTS", - message: "levelName is required", - details: nil - )) - return - } - - controller.loadLevel(levelName) - result(nil) - } - - private func handleApplyQualitySettings(call: FlutterMethodCall, result: @escaping FlutterResult) { - guard let controller = getController(from: call), - let settings = call.arguments as? [String: Any] else { - result(FlutterError( - code: "INVALID_ARGUMENTS", - message: "settings are required", - details: nil - )) - return - } - - controller.applyQualitySettings(settings) - result(nil) - } - - private func handleGetQualitySettings(call: FlutterMethodCall, result: @escaping FlutterResult) { - guard let controller = getController(from: call) else { - result(FlutterError( - code: "NO_CONTROLLER", - message: "Controller not found", - details: nil - )) - return - } - - if let settings = controller.getQualitySettings() { - result(settings) - } else { - result(FlutterError( - code: "SETTINGS_ERROR", - message: "Failed to get quality settings", - details: nil - )) - } - } - - private func handleIsInBackground(call: FlutterMethodCall, result: @escaping FlutterResult) { - guard let controller = getController(from: call) else { - result(FlutterError( - code: "NO_CONTROLLER", - message: "Controller not found", - details: nil - )) - return - } - - let isBackground = controller.isInBackground() - result(isBackground) - } - - // MARK: - Helper Methods - - private func getController(from call: FlutterMethodCall) -> UnrealEngineController? { - guard let args = call.arguments as? [String: Any], - let viewId = args["viewId"] as? Int else { - return nil - } - - return controllers[viewId] - } - - // MARK: - Cleanup - - deinit { - // Clean up all controllers - for (_, controller) in controllers { - controller.quit() - } - controllers.removeAll() + // The framework holds the registrar and registers the view for us, so + // this works whatever order the plugins happen to load in. + GameframeworkPlugin.registerPlatformView(engineType: engineType) } } diff --git a/packages/gameframework/macos/Classes/Core/GameEngineController.swift b/packages/gameframework/macos/Classes/Core/GameEngineController.swift new file mode 100644 index 0000000..287cd02 --- /dev/null +++ b/packages/gameframework/macos/Classes/Core/GameEngineController.swift @@ -0,0 +1,283 @@ +import FlutterMacOS +import Foundation + +/** + * Container view that resizes the engine's view to match its own bounds. + * + * AppKit does not resize subviews for you the way autoresizing hints imply, so + * this does it in layout, and tells whoever is interested that the size + * changed. An engine rendering into its own surface has to be told: stretching + * the view alone leaves it drawing at whatever size it started at. + */ +class GameEngineContainerView: NSView { + weak var engineView: NSView? + + /// Fires after the engine view has been stretched to match the container. + var onEngineViewResized: ((CGSize) -> Void)? + + override var isFlipped: Bool { true } + + override func layout() { + super.layout() + + if let engineView = engineView, !bounds.isEmpty { + engineView.frame = bounds + onEngineViewResized?(bounds.size) + } + } +} + +/** + * Protocol defining the interface for game engine platform views on macOS. + */ +public protocol GameEnginePlatformView: AnyObject { + func createEngine() + func attachEngine() + func detachEngine() + func pauseEngine() + func resumeEngine() + func unloadEngine() + func destroyEngine() + func sendMessage(target: String, method: String, data: String) + + var engineType: String { get } + var engineVersion: String { get } + + func view() -> NSView +} + +/** + * Base controller for an embedded engine on macOS. + * + * The same shape as the iOS one, and deliberately so: it answers the same + * method channel and sends the same events, so the Dart side does not need to + * know which platform it is talking to. What differs is only what AppKit + * forces, which is the container's layout and the platform view protocol. + */ +open class GameEngineController: NSObject, GameEnginePlatformView, FlutterStreamHandler { + + public let viewId: Int64 + public let messenger: FlutterBinaryMessenger + public let channel: FlutterMethodChannel + public let eventChannel: FlutterEventChannel + + private var eventSink: FlutterEventSink? + + /// Events raised before Flutter subscribed. + /// + /// The engine is created and reports itself ready well before the Dart side + /// has listened, and an event sent to nobody is simply lost. Queued here and + /// flushed on subscribe, because the one that goes missing is onCreated, + /// without which the controller never believes the engine exists. + private var pendingEvents: [[String: Any]] = [] + private let eventQueueLock = NSLock() + + private let containerView: GameEngineContainerView + private var engineView: NSView? + + open var _isReady = false + open var _isPaused = false + + private let config: [String: Any] + + public init( + frame: CGRect, + viewId: Int64, + messenger: FlutterBinaryMessenger, + config: [String: Any] + ) { + self.viewId = viewId + self.messenger = messenger + self.config = config + self.containerView = GameEngineContainerView(frame: frame) + + self.channel = FlutterMethodChannel( + name: "com.xraph.gameframework/engine_\(viewId)", + binaryMessenger: messenger + ) + + self.eventChannel = FlutterEventChannel( + name: "com.xraph.gameframework/events_\(viewId)", + binaryMessenger: messenger + ) + + super.init() + + self.channel.setMethodCallHandler(handleMethodCall) + self.eventChannel.setStreamHandler(self) + + // Weakly, because the controller owns the container. + self.containerView.onEngineViewResized = { [weak self] size in + self?.engineViewDidResize(to: size) + } + } + + // MARK: - Abstract + + open func createEngine() { fatalError("createEngine() must be overridden") } + open func attachEngine() { fatalError("attachEngine() must be overridden") } + open func detachEngine() { fatalError("detachEngine() must be overridden") } + open func pauseEngine() { fatalError("pauseEngine() must be overridden") } + open func resumeEngine() { fatalError("resumeEngine() must be overridden") } + open func unloadEngine() { fatalError("unloadEngine() must be overridden") } + open func destroyEngine() { fatalError("destroyEngine() must be overridden") } + open func sendMessage(target: String, method: String, data: String) { + fatalError("sendMessage() must be overridden") + } + + open var engineType: String { fatalError("engineType must be overridden") } + open var engineVersion: String { fatalError("engineVersion must be overridden") } + + /// Undo unloadEngine. Default does nothing, for an engine that cannot. + open func reloadEngine() {} + + /// Called when the container resized the engine view. Override to tell the + /// engine its new surface size. + open func engineViewDidResize(to size: CGSize) {} + + public var isReady: Bool { _isReady } + public var isPaused: Bool { _isPaused } + + // MARK: - Platform view + + public func view() -> NSView { + return containerView + } + + /// Put the engine's view inside the container, where Flutter composites it. + public func addEngineView(_ view: NSView) { + engineView = view + containerView.engineView = view + + view.frame = containerView.bounds + view.autoresizingMask = [.width, .height] + containerView.addSubview(view) + containerView.needsLayout = true + } + + /// Take it back out, without destroying it. + public func removeEngineView() { + engineView?.removeFromSuperview() + containerView.engineView = nil + engineView = nil + } + + // MARK: - Method channel + + private func handleMethodCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + // Every call from Dart, named. This is the boundary that tells you + // whether a control that "does nothing" ever left Dart. + NSLog("GameEngineController: <- \(call.method)") + + switch call.method { + case "engine#create": + createEngine() + result(true) + + case "engine#reload": + reloadEngine() + result(true) + + case "engine#pause": + pauseEngine() + result(nil) + + case "engine#resume": + resumeEngine() + result(nil) + + case "engine#unload": + unloadEngine() + result(nil) + + case "engine#quit": + destroyEngine() + result(nil) + + case "engine#isReady": + result(isReady) + + case "engine#isPaused": + result(isPaused) + + case "engine#isLoaded": + result(isReady) + + case "engine#isInBackground": + result(isPaused) + + case "engine#sendMessage": + guard let args = call.arguments as? [String: Any], + let target = args["target"] as? String, + let method = args["method"] as? String, + let data = args["data"] as? String else { + result(FlutterError(code: "INVALID_ARGS", + message: "Invalid arguments", + details: nil)) + return + } + sendMessage(target: target, method: method, data: data) + result(nil) + + case "events#setup": + // Answered so the Dart side knows the handler is live before it + // subscribes. Without this it can listen too early and miss the + // events already queued. + result(true) + + default: + result(FlutterMethodNotImplemented) + } + } + + // MARK: - Events + + public func sendEvent(name: String, data: Any?) { + let event: [String: Any] = [ + "event": name, + "data": data ?? NSNull() + ] + + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + if let sink = self.eventSink { + sink(event) + } else { + self.eventQueueLock.lock() + self.pendingEvents.append(event) + self.eventQueueLock.unlock() + NSLog("GameEngineController: Queued event '\(name)' (Flutter not subscribed yet)") + } + } + } + + public func onListen(withArguments arguments: Any?, + eventSink events: @escaping FlutterEventSink) -> FlutterError? { + eventSink = events + + eventQueueLock.lock() + let queued = pendingEvents + pendingEvents.removeAll() + eventQueueLock.unlock() + + if !queued.isEmpty { + NSLog("GameEngineController: Flushing \(queued.count) pending events to Flutter") + for event in queued { + events(event) + } + } + + return nil + } + + public func onCancel(withArguments arguments: Any?) -> FlutterError? { + eventSink = nil + return nil + } + + /// Read a value from the config Flutter passed when creating the view. + public func getConfigValue(_ key: String) -> T? { + return config[key] as? T + } +} diff --git a/packages/gameframework/macos/Classes/Core/GameEngineRegistry.swift b/packages/gameframework/macos/Classes/Core/GameEngineRegistry.swift new file mode 100644 index 0000000..490d8c4 --- /dev/null +++ b/packages/gameframework/macos/Classes/Core/GameEngineRegistry.swift @@ -0,0 +1,116 @@ +import FlutterMacOS +import Foundation + +/** + * Protocol for engine factories + * + * Engine plugins must provide a factory that creates their specific controller. + */ +public protocol GameEngineFactory { + /// Create an engine controller + func createController( + frame: CGRect, + viewId: Int64, + messenger: FlutterBinaryMessenger, + config: [String: Any] + ) -> GameEnginePlatformView +} + +/** + * Singleton registry for game engine implementations + * + * Manages the registration and lifecycle of engine controllers and factories. + */ +public class GameEngineRegistry { + + public static let shared = GameEngineRegistry() + + private init() {} + + private var factories: [String: GameEngineFactory] = [:] + private var controllers: [GameEnginePlatformView] = [] + + /// Register an engine factory + public func registerFactory(engineType: String, factory: GameEngineFactory) { + factories[engineType] = factory + } + + /// Unregister an engine factory + public func unregisterFactory(engineType: String) { + factories.removeValue(forKey: engineType) + } + + /// Check if an engine is registered + public func isEngineRegistered(_ engineType: String) -> Bool { + return factories[engineType] != nil + } + + /// Get all registered engine types + public func getRegisteredEngines() -> [String] { + return Array(factories.keys) + } + + /// Get factory for a specific engine type + public func getFactory(_ engineType: String) -> GameEngineFactory? { + return factories[engineType] + } + + /// Keep a controller alive for as long as its platform view exists + public func registerController(_ controller: GameEnginePlatformView) { + controllers.append(controller) + } + + /// Drop a controller once its platform view is gone + public func unregisterController(_ controller: GameEnginePlatformView) { + controllers.removeAll { $0 === controller } + } +} + +/** + * Platform view factory for game engines + * + * Wraps the GameEngineFactory protocol for Flutter's platform view system. + * + * The macOS protocol differs from the iOS one: it hands over a view identifier + * and arguments but no frame, because AppKit sizes the view from its container + * afterwards. The controller is built with a zero frame and laid out on the + * first pass, which is what the iOS side ends up doing anyway. + */ +public class GameEnginePlatformViewFactory: NSObject, FlutterPlatformViewFactory { + + private let messenger: FlutterBinaryMessenger + private let engineType: String + + public init(messenger: FlutterBinaryMessenger, engineType: String) { + self.messenger = messenger + self.engineType = engineType + super.init() + } + + public func create(withViewIdentifier viewId: Int64, arguments args: Any?) -> NSView { + let config = args as? [String: Any] ?? [:] + + guard let factory = GameEngineRegistry.shared.getFactory(engineType) else { + // Not fatal, unlike iOS. A missing engine should show an empty view + // and say why, rather than take the whole app down: on desktop this + // is usually a plugin that failed to register, and killing the app + // hides the one message that would tell you so. + NSLog("GameEngineRegistry: no factory for \(engineType); showing an empty view") + return NSView(frame: .zero) + } + + let controller = factory.createController( + frame: .zero, + viewId: viewId, + messenger: messenger, + config: config + ) + + GameEngineRegistry.shared.registerController(controller) + return controller.view() + } + + public func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol { + return FlutterStandardMessageCodec.sharedInstance() + } +} diff --git a/packages/gameframework/macos/Classes/GameframeworkPlugin.swift b/packages/gameframework/macos/Classes/GameframeworkPlugin.swift index 08c126d..ec1d483 100644 --- a/packages/gameframework/macos/Classes/GameframeworkPlugin.swift +++ b/packages/gameframework/macos/Classes/GameframeworkPlugin.swift @@ -2,12 +2,38 @@ import Cocoa import FlutterMacOS public class GameframeworkPlugin: NSObject, FlutterPlugin { + + /// Kept so an engine plugin can register its platform view later. + /// + /// Plugin registration order is not something a plugin can rely on, and an + /// engine cannot register a view for a factory it has not created yet. So the + /// registrar is held here and engine plugins call back once they are ready. + private static var pluginRegistrar: FlutterPluginRegistrar? + public static func register(with registrar: FlutterPluginRegistrar) { + pluginRegistrar = registrar + let channel = FlutterMethodChannel(name: "gameframework", binaryMessenger: registrar.messenger) let instance = GameframeworkPlugin() registrar.addMethodCallDelegate(instance, channel: channel) } + /// Called by engine plugins once they have registered their factory. + public static func registerPlatformView(engineType: String) { + guard let registrar = pluginRegistrar else { + NSLog("GameframeworkPlugin: no registrar yet, cannot register a platform view for \(engineType)") + return + } + + let factory = GameEnginePlatformViewFactory( + messenger: registrar.messenger, + engineType: engineType + ) + + registrar.registerViewFactory(factory, withId: "com.xraph.gameframework/\(engineType)") + NSLog("GameframeworkPlugin: registered platform view com.xraph.gameframework/\(engineType)") + } + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { switch call.method { case "getPlatformVersion": From dae79de7c9a8a2b472ee1d6bfa6086bdb40127b7 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Mon, 7 Sep 2026 15:43:31 -0500 Subject: [PATCH 22/25] chore(macos): wire the podspec and enable macOS in the example config Vendors the framework when it is sitting beside the podspec, ships the content, and depends on gameframework, which the rewritten controller imports. The plugin registration uses register rather than registerViewFactory. The Objective-C header declares the longer name and Swift imports it under the shorter one, so reading the header gives you the wrong answer and only the compiler tells you. --- .../dart/macos/gameframework_unreal.podspec | 25 ++++++++++--- .../Private/Mac/FlutterView_Mac.mm | 36 +++++++++++++++++-- example/.game.yml | 2 +- .../macos/Classes/GameframeworkPlugin.swift | 4 ++- 4 files changed, 59 insertions(+), 8 deletions(-) diff --git a/engines/unreal/dart/macos/gameframework_unreal.podspec b/engines/unreal/dart/macos/gameframework_unreal.podspec index f33f75e..acc8d13 100644 --- a/engines/unreal/dart/macos/gameframework_unreal.podspec +++ b/engines/unreal/dart/macos/gameframework_unreal.podspec @@ -17,15 +17,32 @@ console commands, and level loading for Unreal Engine in Flutter apps. s.source = { :path => '.' } s.source_files = 'Classes/**/*' s.dependency 'FlutterMacOS' + s.dependency 'gameframework' s.platform = :osx, '10.14' s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } s.swift_version = '5.0' - # Unreal Framework dependency - # Note: UnrealFramework.framework must be manually added to the macOS project - # This podspec does not directly link the framework, as it's typically - # bundled with the game project + # Vendor the framework when it is sitting right here, which is the case after + # "game sync unreal -p macos" with no separate game plugin in between. + # Without this nothing embeds it and the app launches with the engine missing. + unreal_framework_path = File.join(__dir__, 'UnrealFramework.framework') + if File.exist?(unreal_framework_path) || File.symlink?(unreal_framework_path) + s.osx.vendored_frameworks = 'UnrealFramework.framework' + end + + # Cooked content and the command line, shipped into the app bundle. + # + # Unreal reads these from beside the executable, so they cannot live inside + # the framework. Named per entry rather than globbed, because a glob matches + # files and CocoaPods copies each one to the bundle root, flattening any + # directory structure. + unreal_content_path = File.join(__dir__, 'UnrealContent') + if File.directory?(unreal_content_path) + s.resources = Dir.glob(File.join(unreal_content_path, '*')).map do |entry| + File.join('UnrealContent', File.basename(entry)) + end + end s.frameworks = 'Cocoa', 'Foundation', 'Metal', 'MetalKit', 'CoreGraphics', 'QuartzCore', 'CoreVideo' # Enable Objective-C++ compilation for bridge files diff --git a/engines/unreal/plugin/Source/FlutterPlugin/Private/Mac/FlutterView_Mac.mm b/engines/unreal/plugin/Source/FlutterPlugin/Private/Mac/FlutterView_Mac.mm index 562958b..86337eb 100644 --- a/engines/unreal/plugin/Source/FlutterPlugin/Private/Mac/FlutterView_Mac.mm +++ b/engines/unreal/plugin/Source/FlutterPlugin/Private/Mac/FlutterView_Mac.mm @@ -81,10 +81,42 @@ int32_t UnrealBridge_StartEngine(void) return 1; } - // The command line normally comes from argv. There is no argv in a library, - // so the engine gets an empty one and reads the rest from its own config. + // The command line normally comes from argv, and a library has none. Read + // it from uecommandline.txt beside the executable instead, which is the + // convention iOS already uses, so a host places the same file on both + // platforms and this does not become another thing to know. + // + // It matters more here than on iOS: a Mac build running uncooked content + // finds the project only if -project points at it. GEmbeddedCommandLine = TEXT(""); + NSString* CommandLinePath = + [[NSBundle mainBundle] pathForResource:@"uecommandline" ofType:@"txt"]; + if (CommandLinePath == nil) + { + // Resources are one place; the bundle root is the other, and that is + // where a staged build puts it. + CommandLinePath = [[[NSBundle mainBundle] bundlePath] + stringByAppendingPathComponent:@"uecommandline.txt"]; + } + + NSString* Contents = [NSString stringWithContentsOfFile:CommandLinePath + encoding:NSUTF8StringEncoding + error:nil]; + if (Contents != nil) + { + GEmbeddedCommandLine = FString( + [[Contents stringByTrimmingCharactersInSet: + [NSCharacterSet whitespaceAndNewlineCharacterSet]] UTF8String]); + UE_LOG(LogTemp, Log, TEXT("[FlutterView_Mac] Command line: %s"), *GEmbeddedCommandLine); + } + else + { + UE_LOG(LogTemp, Warning, + TEXT("[FlutterView_Mac] No uecommandline.txt beside the app, so the engine " + "has no project to open and will not load a level")); + } + // Start the game thread the way LaunchMac does. RunGameThread registers the // calling thread as the main one and puts GuardedMain on a thread of its // own, which is what every later assumption about game and main threads diff --git a/example/.game.yml b/example/.game.yml index d7caf98..cc5a083 100644 --- a/example/.game.yml +++ b/example/.game.yml @@ -24,7 +24,7 @@ engines: target_path: ios/UnrealFramework.framework macos: - enabled: false + enabled: true target_path: macos/UnrealFramework.framework windows: diff --git a/packages/gameframework/macos/Classes/GameframeworkPlugin.swift b/packages/gameframework/macos/Classes/GameframeworkPlugin.swift index ec1d483..78eceb9 100644 --- a/packages/gameframework/macos/Classes/GameframeworkPlugin.swift +++ b/packages/gameframework/macos/Classes/GameframeworkPlugin.swift @@ -30,7 +30,9 @@ public class GameframeworkPlugin: NSObject, FlutterPlugin { engineType: engineType ) - registrar.registerViewFactory(factory, withId: "com.xraph.gameframework/\(engineType)") + // register, not registerViewFactory. The Objective-C header declares + // registerViewFactory:withId:, and Swift imports it under the shorter name. + registrar.register(factory, withId: "com.xraph.gameframework/\(engineType)") NSLog("GameframeworkPlugin: registered platform view com.xraph.gameframework/\(engineType)") } From e3b5d8fed366b846f4f5bf076ddf01e60eb7d8ed Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Mon, 7 Sep 2026 15:46:27 -0500 Subject: [PATCH 23/25] docs(unreal): leave a note for whoever picks this up next Where iOS and macOS actually stand, what was seen working versus inferred, and the constraints that cost the most to find: the tick being the main thread rather than the game thread, Unreal's log buffering hiding runtime lines, and the opposite framework layout rules on the two platforms. The macOS next step is a Mac editor build and a cook, which is hours, so it is written down rather than half started. Also records that Unity's macOS controller does not compile, which breaks any app depending on both engines for macOS. Left alone, since Unity could not be tested here. --- .gitignore | 4 + engines/unreal/CONTINUE.md | 75 +++++++++++++++++++ .../Private/Mac/FlutterView_Mac.mm | 36 ++++++++- 3 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 engines/unreal/CONTINUE.md diff --git a/.gitignore b/.gitignore index 5b3b33c..0038b1e 100644 --- a/.gitignore +++ b/.gitignore @@ -294,3 +294,7 @@ app.*.symbols engines/unreal/dart/ios/UnrealContent/ engines/unreal/dart/macos/UnrealContent/ example/unreal/demo_exports/ + +# Content synced into the example app by "game sync unreal", rebuilt on demand. +example/macos/UnrealContent/ +example/ios/UnrealContent/ diff --git a/engines/unreal/CONTINUE.md b/engines/unreal/CONTINUE.md new file mode 100644 index 0000000..14d1489 --- /dev/null +++ b/engines/unreal/CONTINUE.md @@ -0,0 +1,75 @@ +# Where the Unreal work stands + +Written 2026-09-07, at the end of a long session. Everything here was seen +working or seen failing on this machine, not inferred. + +## iOS: working, verified on device + +Unreal renders inside a `GameWidget` on an iPhone 16 Pro at full native +portrait resolution. Drag orbits, pinch zooms, the HUD controls drive the +cube, and camera state streams back. Pause freezes the scene, unload releases +the view and reload brings it back mid-scene. + +Three commands, no hand editing: + + game export unreal -p ios && game sync unreal -p ios && flutter build ios + +`flutter run` times out installing 565MB over wireless. USB is fine. + +## macOS: builds, starts, stops at shaders + +Further than it looks, and not finished. The app builds, the framework loads, +the engine starts, reads its command line, opens the project and initialises +Metal. It then fails: + + LogShaderLibrary: Error: Failed to initialize ShaderCodeLibrary ... + part of the Global shader library is missing + +Nothing has been cooked for Mac, and a non-editor build cannot compile shaders +at runtime. + +**Next step, and it is a long one.** Build the Mac editor from source, cook the +project for Mac, then re-export. The engine build alone took 78 minutes for the +game target; the editor is bigger. After that the framework should have what it +needs, and the next unknown is whether reparenting the engine's `FCocoaWindow` +content view into the Flutter platform view actually renders. That has never +run, so treat it as unproven rather than merely untested. + +Two things a macOS host needs, which the export prints but nothing enforces: + +- The app must not be sandboxed for an uncooked run, because it reads the + project from a path outside its container. Cooked content in the bundle + removes this. +- The engine finds its own content relative to the executable, so it needs + `-basedir=/Engine/Binaries/Mac`. That is read from the process argv, + not from `uecommandline.txt`, so it cannot be set from inside the library. + A staged layout beside the app would avoid it. + +## Android + +The UPL migration is in and the library-mode Java is injected at build time. +Not run on a device in this session, so treat it as built but unverified. + +## Things worth knowing before changing anything + +- `BUILD_EMBEDDED_APP` is defined only by `UEBuildIOS.cs`. The Mac target + defines it itself and takes `TargetBuildEnvironment.Unique` so it reaches + Core, which is why the Mac build rebuilds the engine. +- The host's tick is a display link on the **main thread**, not Unreal's game + loop. `TickGameThread` drains its queue on whoever calls it, so anything + touching the renderer must not run there. `FTSTicker` is the way onto the + real game thread; `RunOnGameThread` is not. +- Unreal's log file is buffered and mostly shows startup. Do not conclude + anything from a missing runtime line. Route diagnostics back over the bridge + instead; `flutter.TraceMessages 1` turns the message trace on. +- A flat iOS framework must not contain `Resources/`. A versioned macOS one + must carry `Versions/A/Resources/Info.plist`. These are opposite rules and + both are enforced by tooling that blames something else. + +## Known broken, and not mine + +`engines/unity/dart/macos/Classes/UnityEngineController.swift` does not +compile: it references `FlutterPlatformView`, which does not exist on macOS, +and `UnityFramework`, and it declares two methods with the same Objective-C +selector. Any Flutter app depending on both engines fails to build for macOS +because of it. Untouched here, since Unity could not be tested. diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/Mac/FlutterView_Mac.mm b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/Mac/FlutterView_Mac.mm index 562958b..86337eb 100644 --- a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/Mac/FlutterView_Mac.mm +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/Mac/FlutterView_Mac.mm @@ -81,10 +81,42 @@ int32_t UnrealBridge_StartEngine(void) return 1; } - // The command line normally comes from argv. There is no argv in a library, - // so the engine gets an empty one and reads the rest from its own config. + // The command line normally comes from argv, and a library has none. Read + // it from uecommandline.txt beside the executable instead, which is the + // convention iOS already uses, so a host places the same file on both + // platforms and this does not become another thing to know. + // + // It matters more here than on iOS: a Mac build running uncooked content + // finds the project only if -project points at it. GEmbeddedCommandLine = TEXT(""); + NSString* CommandLinePath = + [[NSBundle mainBundle] pathForResource:@"uecommandline" ofType:@"txt"]; + if (CommandLinePath == nil) + { + // Resources are one place; the bundle root is the other, and that is + // where a staged build puts it. + CommandLinePath = [[[NSBundle mainBundle] bundlePath] + stringByAppendingPathComponent:@"uecommandline.txt"]; + } + + NSString* Contents = [NSString stringWithContentsOfFile:CommandLinePath + encoding:NSUTF8StringEncoding + error:nil]; + if (Contents != nil) + { + GEmbeddedCommandLine = FString( + [[Contents stringByTrimmingCharactersInSet: + [NSCharacterSet whitespaceAndNewlineCharacterSet]] UTF8String]); + UE_LOG(LogTemp, Log, TEXT("[FlutterView_Mac] Command line: %s"), *GEmbeddedCommandLine); + } + else + { + UE_LOG(LogTemp, Warning, + TEXT("[FlutterView_Mac] No uecommandline.txt beside the app, so the engine " + "has no project to open and will not load a level")); + } + // Start the game thread the way LaunchMac does. RunGameThread registers the // calling thread as the main one and puts GuardedMain on a thread of its // own, which is what every later assumption about game and main threads From 11431bae61c053904a0e1404f47e9216325192d3 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Mon, 7 Sep 2026 16:06:34 -0500 Subject: [PATCH 24/25] fix(unity/macos): make it compile, so both engines build together The macOS Unity controller did not build, which broke the macOS build of any app depending on both engines. Three separate problems. It conformed to FlutterPlatformView, which does not exist on macOS. The conformance was doing nothing either: its factory already returns an NSView, which is what the macOS platform view protocol asks for. It named the UnityFramework type. That type is not available when building, because UnityFramework.framework is assembled per game and loaded from the app bundle at runtime, so the plugin could not compile without a Unity build on hand. It goes through an @objc protocol now, declaring only the handful of methods this controller sends, which is what Objective-C was doing anyway. The selectors are worth checking against Unity's header: a mismatch compiles and then fails at runtime as an unrecognised selector. And two methods carried the same Objective-C selector, which is an error on one class. The Swift-side convenience is no longer @objc. Verified by building the example, which depends on both engines, for macOS. Not verified further: Unity cannot be run here, so this is compiling rather than working, and the README says so. README now reports what each engine actually does per platform rather than ready/WIP/planned, including that Windows and Linux are stubs with no controller and no platform view, so a GameWidget there renders nothing. --- README.md | 47 ++++++++-- .../dart/FLUTTER_UNITY_WIDGET_ASSESSMENT.md | 94 +++++++++++++++++++ .../macos/Classes/UnityEngineController.swift | 62 ++++++++++-- engines/unreal/CONTINUE.md | 24 +++-- 4 files changed, 202 insertions(+), 25 deletions(-) create mode 100644 engines/unity/dart/FLUTTER_UNITY_WIDGET_ASSESSMENT.md diff --git a/README.md b/README.md index 2326321..f901ccb 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Game Framework provides a consistent API for integrating game engines into Flutt - **Modular Architecture** - Use only the engines you need - **Bidirectional Communication** - Flutter ↔ Engine messaging with type safety - **Lifecycle Management** - Automatic pause/resume/destroy handling -- **Multi-Platform** - Android, iOS, macOS, Windows, Linux support +- **Multi-Platform** - Android, iOS and web today, with macOS in progress - **Production Ready** - Comprehensive testing and documentation ## Monorepo Structure @@ -256,14 +256,43 @@ Game Engine (Unity/Unreal) ## Platform Support -| Platform | gameframework | Unity | Unreal | Status | -|----------|--------------|-------|--------|--------| -| Android | ✅ Ready | ✅ Ready | 🚧 WIP | Stable | -| iOS | ✅ Ready | ✅ Ready | 🚧 WIP | Stable | -| Web | ✅ Ready | ✅ Ready | ⏳ Planned | Stable | -| macOS | ✅ Ready | 🚧 WIP | ⏳ Planned | Beta | -| Windows | ✅ Ready | 🚧 WIP | ⏳ Planned | Beta | -| Linux | ✅ Ready | 🚧 WIP | ⏳ Planned | Beta | +| Platform | Unity | Unreal | +|----------|-------|--------| +| Android | Working | Built, not yet run on a device | +| iOS | Working | Working, verified on device | +| Web | Working | Not started | +| macOS | Builds; not run | Starts and reaches Metal, does not render yet | +| Windows | Stub | Stub | +| Linux | Stub | Stub | + +"Stub" means the platform directory holds a CMake file and an empty plugin +entry point. There is no controller and no platform view, so a `GameWidget` +there renders nothing. Web is Unity only; Unreal has no web target. + +### Unreal on iOS + +Working end to end, three commands and no hand editing: + +```bash +game export unreal -p ios && game sync unreal -p ios && flutter build ios +``` + +Rendering, touch, messaging both ways, pause, and unload. Needs an engine +built from source: an installed engine ships its modules prebuilt, so +`BUILD_EMBEDDED_APP` never reaches them and the framework links, launches and +never boots an engine. + +### Unreal on macOS + +Further than the table suggests, and not finished. Unreal has no embedded mode +for Mac at all, so the target defines `BUILD_EMBEDDED_APP` itself and the +plugin supplies the startup and view path the engine provides only for iOS. +The app builds, the framework loads, the engine starts, opens the project and +initialises Metal. It then stops because nothing has been cooked for Mac and a +non-editor build cannot compile shaders at runtime. + +See `engines/unreal/CONTINUE.md` for what is left and the constraints worth +knowing before changing any of it. ## Continuous Integration diff --git a/engines/unity/dart/FLUTTER_UNITY_WIDGET_ASSESSMENT.md b/engines/unity/dart/FLUTTER_UNITY_WIDGET_ASSESSMENT.md new file mode 100644 index 0000000..baa108f --- /dev/null +++ b/engines/unity/dart/FLUTTER_UNITY_WIDGET_ASSESSMENT.md @@ -0,0 +1,94 @@ +# Flutter Unity Widget Assessment + +This document summarizes findings from reviewing the [flutter-unity-view-widget](https://github.com/juicycleff/flutter-unity-view-widget) project and how those insights informed the Game Framework's Unity integration architecture. + +## Assessment Purpose + +The flutter-unity-view-widget was reviewed for: +- Learning best practices for Unity-Flutter integration +- Understanding platform-specific patterns +- Identifying architectural improvements for the Game Framework +- Comparison of approaches (not for integration or replacement) + +## Key Architectural Differences + +### 1. Plugin Architecture + +| Aspect | flutter-unity-view-widget | Game Framework | +|--------|--------------------------|----------------| +| Scope | Unity-only plugin | Multi-engine framework (Unity, Unreal) | +| API | Unity-specific API | Unified `GameEngineController` interface | +| Registration | Direct widget | Factory + Registry pattern | +| Platform support | Android, iOS, Web | Android, iOS, Web, macOS, Windows, Linux | + +### 2. Communication Patterns + +**flutter-unity-view-widget:** +- Uses `UnitySendMessage` (Flutter to Unity) +- Uses `onUnityMessage` callback (Unity to Flutter) +- Simple string-based messaging + +**Game Framework:** +- Structured messaging with target, method, and data fields +- Binary message support with compression +- Batch message support for high-frequency scenarios +- Message queuing for pre-initialization messages +- `MessageRouter` for automatic routing to `FlutterMonoBehaviour` instances + +### 3. iOS Bridge Mechanism + +**flutter-unity-view-widget:** +- Uses `dlsym` to find C functions across frameworks +- Fragile: symbols may be stripped or unavailable + +**Game Framework:** +- Uses `FlutterBridgeRegistry` discoverable via Objective-C runtime (`NSClassFromString`) +- More reliable: Objective-C runtime works across dynamically loaded frameworks +- Also provides `@_cdecl` Swift functions as backup + +### 4. Android Integration + +**flutter-unity-view-widget:** +- Custom `UnityPlayerActivity` subclass +- Direct reference to Unity player + +**Game Framework:** +- `FlutterBridgeRegistry` Kotlin singleton accessible via JNI +- Uses Activity classloader for reliable class discovery +- Separates bridge registry from Unity player lifecycle + +## Lessons Learned + +### What We Adopted + +1. **Unity as a Library pattern**: Both projects use Unity's "Unity as a Library" feature to embed Unity in a Flutter app. This is the correct approach. + +2. **XCodePostBuild processing**: The concept of post-build Xcode project modification (adding Data folder, configuring build settings) was validated by flutter-unity-view-widget's approach. + +3. **Android manifest fixing**: Removing the LAUNCHER intent-filter from Unity's AndroidManifest.xml is critical for embedded mode. Both projects address this. + +### What We Improved + +1. **Message queuing**: Messages sent before Unity is ready are queued and flushed in order. flutter-unity-view-widget drops these messages. + +2. **Multi-engine support**: The Game Framework's registry/factory pattern allows multiple engine types (Unity, Unreal) to coexist. flutter-unity-view-widget is tightly coupled to Unity. + +3. **Bridge reliability**: Using Objective-C runtime (`NSClassFromString`) instead of `dlsym` for iOS bridge is more reliable across framework loading scenarios. + +4. **Structured messaging**: The Game Framework supports structured messages with target/method/data, binary messages, batch messages, and automatic routing via `FlutterMonoBehaviour`. + +5. **CLI tooling**: The `game-cli` provides automated export, sync, and publish workflows. flutter-unity-view-widget requires manual Unity builds and file copying. + +6. **Platform expansion**: The Game Framework's architecture made it straightforward to add WebGL and macOS support using the same patterns established for iOS and Android. + +### What We Chose Differently + +1. **Distribution model for web**: flutter-unity-view-widget doesn't address web builds. The Game Framework uses an artifact-based distribution model where WebGL builds are uploaded to a CDN and loaded at runtime, keeping the Flutter package size manageable. + +2. **macOS as integrated framework**: The Game Framework treats macOS the same as iOS (framework bundled with the app), rather than requiring a separate runtime download. + +3. **Build automation**: Instead of requiring developers to manually configure Unity builds, the Game Framework's `FlutterBuildScript.cs` provides CLI-compatible build methods that the `game-cli` can invoke automatically. + +## Conclusion + +The flutter-unity-view-widget validated several core patterns for Unity-Flutter integration. The Game Framework builds on these patterns with a more robust, extensible architecture that supports multiple engines, platforms, and distribution models. The unified API (`GameEngineController`) makes it possible to swap engines without changing application code, while the `game-cli` tooling reduces the operational burden of managing Unity builds. diff --git a/engines/unity/dart/macos/Classes/UnityEngineController.swift b/engines/unity/dart/macos/Classes/UnityEngineController.swift index 27823a1..d610471 100644 --- a/engines/unity/dart/macos/Classes/UnityEngineController.swift +++ b/engines/unity/dart/macos/Classes/UnityEngineController.swift @@ -21,7 +21,36 @@ import Cocoa * - Proper NSView lifecycle management for embedding Unity in Flutter * - Error handling with descriptive error events */ -public class UnityEngineController: NSObject, FlutterPlatformView { +/// The part of UnityFramework this controller uses. +/// +/// The type itself is not available when building: UnityFramework.framework is +/// assembled per game and loaded from the app bundle at runtime, so naming the +/// class here would make the plugin impossible to compile without a Unity build +/// on hand. The instance arrives through the bundle's principal class and is +/// messaged through this, which is what Objective-C was doing anyway. +/// +/// Keep the selectors exact. A mismatch here compiles and then fails as an +/// unrecognised selector at runtime, which is a much worse place to find out. +@objc protocol UnityFrameworkInterface { + @objc func setDataBundleId(_ bundleId: String) + + @objc func runEmbedded(withArgc argc: Int32, + argv: UnsafeMutablePointer?>?, + appLaunchOpts: [AnyHashable: Any]?) + + @objc func appController() -> NSViewController? + + @objc func pause(_ paused: Bool) + + @objc func sendMessageToGO(withName name: String, + functionName: String, + message: String) + + @objc optional func unloadApplication() + @objc optional func quitApplication(_ exitCode: Int32) +} + +public class UnityEngineController: NSObject { // MARK: - Static Active Controller Tracking @@ -198,7 +227,12 @@ public class UnityEngineController: NSObject, FlutterPlatformView { } // Register the Unity framework with the FlutterBridgeRegistry - FlutterBridgeRegistry.register(unityFramework: unityFramework) + // The registry stores it as an NSObject, which every Unity + // framework instance is; the protocol is only how this file talks + // to it. + if let asObject = unityFramework as? NSObject { + FlutterBridgeRegistry.register(unityFramework: asObject) + } // Set up Unity framework unityFramework.setDataBundleId("com.unity3d.framework") @@ -211,8 +245,7 @@ public class UnityEngineController: NSObject, FlutterPlatformView { ) // Get Unity's root view - if let appController = unityFramework.appController(), - let rootView = appController.rootViewController?.view { + if let rootView = unityFramework.appController()?.view { self.unityView = rootView // Embed Unity view in our container @@ -325,7 +358,9 @@ public class UnityEngineController: NSObject, FlutterPlatformView { // MARK: - Unity Message Handling (called from C bridge) /// Called from Unity when a message is sent to Flutter - @objc public func onUnityMessage(target: String, method: String, data: String) { + /// Swift-side convenience. Deliberately not @objc: it would carry the same + /// selector as the method below, and two of those on one class is an error. + public func onUnityMessage(target: String, method: String, data: String) { onUnityMessageWithTarget(target, method: method, data: data) } @@ -400,9 +435,9 @@ public class UnityEngineController: NSObject, FlutterPlatformView { // - Data/ = game data // Pre-load GameAssembly.dylib so UnityPlayer can resolve IL2CPP symbols when the bundle loads. - private func loadUnityFramework() -> UnityFramework? { + private func loadUnityFramework() -> UnityFrameworkInterface? { // Try to get from cache first - if let cached = FlutterBridgeRegistry.sharedUnityFramework as? UnityFramework { + if let cached = FlutterBridgeRegistry.sharedUnityFramework as? UnityFrameworkInterface { return cached } @@ -431,8 +466,17 @@ public class UnityEngineController: NSObject, FlutterPlatformView { return nil } - let getInstance = principalClass.getInstance() - return getInstance as? UnityFramework + // getInstance is Unity's own class method, so it has to be sent + // dynamically too rather than called on a type nothing here declares. + let selector = NSSelectorFromString("getInstance") + guard let unityClass = principalClass as? NSObject.Type, + unityClass.responds(to: selector) else { + NSLog("UnityEngineController [macOS]: principal class has no getInstance") + return nil + } + + let instance = unityClass.perform(selector)?.takeUnretainedValue() + return instance as? UnityFrameworkInterface } // MARK: - Cleanup diff --git a/engines/unreal/CONTINUE.md b/engines/unreal/CONTINUE.md index 14d1489..6bd0c8c 100644 --- a/engines/unreal/CONTINUE.md +++ b/engines/unreal/CONTINUE.md @@ -66,10 +66,20 @@ Not run on a device in this session, so treat it as built but unverified. must carry `Versions/A/Resources/Info.plist`. These are opposite rules and both are enforced by tooling that blames something else. -## Known broken, and not mine - -`engines/unity/dart/macos/Classes/UnityEngineController.swift` does not -compile: it references `FlutterPlatformView`, which does not exist on macOS, -and `UnityFramework`, and it declares two methods with the same Objective-C -selector. Any Flutter app depending on both engines fails to build for macOS -because of it. Untouched here, since Unity could not be tested. +## Unity's macOS controller + +It did not compile, which broke the macOS build of any app depending on both +engines. Fixed to the point where both build together, and no further: Unity +could not be run here, so treat macOS Unity as compiling rather than working. + +What was wrong: it conformed to `FlutterPlatformView`, which does not exist on +macOS and which its factory never needed, since that already returns an +`NSView`. It named the `UnityFramework` type, which is not available when +building because the framework is assembled per game and loaded from the +bundle at runtime. And it declared two methods carrying the same Objective-C +selector. + +The framework is reached through an `@objc protocol` now, so the selectors are +declared in one place. Get one wrong and it compiles and then fails at runtime +as an unrecognised selector, so they are worth checking against Unity's own +header before trusting them. From a7344e74153122f5af5e7213734536f62e7ecf31 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Mon, 7 Sep 2026 18:59:15 -0500 Subject: [PATCH 25/25] chore: untrack the Unity widget assessment Swept into the previous commit by an overly broad "git add -A engines/unity". It was an untracked working file and not part of this branch's work. Still on disk, just not tracked. --- .../dart/FLUTTER_UNITY_WIDGET_ASSESSMENT.md | 94 ------------------- 1 file changed, 94 deletions(-) delete mode 100644 engines/unity/dart/FLUTTER_UNITY_WIDGET_ASSESSMENT.md diff --git a/engines/unity/dart/FLUTTER_UNITY_WIDGET_ASSESSMENT.md b/engines/unity/dart/FLUTTER_UNITY_WIDGET_ASSESSMENT.md deleted file mode 100644 index baa108f..0000000 --- a/engines/unity/dart/FLUTTER_UNITY_WIDGET_ASSESSMENT.md +++ /dev/null @@ -1,94 +0,0 @@ -# Flutter Unity Widget Assessment - -This document summarizes findings from reviewing the [flutter-unity-view-widget](https://github.com/juicycleff/flutter-unity-view-widget) project and how those insights informed the Game Framework's Unity integration architecture. - -## Assessment Purpose - -The flutter-unity-view-widget was reviewed for: -- Learning best practices for Unity-Flutter integration -- Understanding platform-specific patterns -- Identifying architectural improvements for the Game Framework -- Comparison of approaches (not for integration or replacement) - -## Key Architectural Differences - -### 1. Plugin Architecture - -| Aspect | flutter-unity-view-widget | Game Framework | -|--------|--------------------------|----------------| -| Scope | Unity-only plugin | Multi-engine framework (Unity, Unreal) | -| API | Unity-specific API | Unified `GameEngineController` interface | -| Registration | Direct widget | Factory + Registry pattern | -| Platform support | Android, iOS, Web | Android, iOS, Web, macOS, Windows, Linux | - -### 2. Communication Patterns - -**flutter-unity-view-widget:** -- Uses `UnitySendMessage` (Flutter to Unity) -- Uses `onUnityMessage` callback (Unity to Flutter) -- Simple string-based messaging - -**Game Framework:** -- Structured messaging with target, method, and data fields -- Binary message support with compression -- Batch message support for high-frequency scenarios -- Message queuing for pre-initialization messages -- `MessageRouter` for automatic routing to `FlutterMonoBehaviour` instances - -### 3. iOS Bridge Mechanism - -**flutter-unity-view-widget:** -- Uses `dlsym` to find C functions across frameworks -- Fragile: symbols may be stripped or unavailable - -**Game Framework:** -- Uses `FlutterBridgeRegistry` discoverable via Objective-C runtime (`NSClassFromString`) -- More reliable: Objective-C runtime works across dynamically loaded frameworks -- Also provides `@_cdecl` Swift functions as backup - -### 4. Android Integration - -**flutter-unity-view-widget:** -- Custom `UnityPlayerActivity` subclass -- Direct reference to Unity player - -**Game Framework:** -- `FlutterBridgeRegistry` Kotlin singleton accessible via JNI -- Uses Activity classloader for reliable class discovery -- Separates bridge registry from Unity player lifecycle - -## Lessons Learned - -### What We Adopted - -1. **Unity as a Library pattern**: Both projects use Unity's "Unity as a Library" feature to embed Unity in a Flutter app. This is the correct approach. - -2. **XCodePostBuild processing**: The concept of post-build Xcode project modification (adding Data folder, configuring build settings) was validated by flutter-unity-view-widget's approach. - -3. **Android manifest fixing**: Removing the LAUNCHER intent-filter from Unity's AndroidManifest.xml is critical for embedded mode. Both projects address this. - -### What We Improved - -1. **Message queuing**: Messages sent before Unity is ready are queued and flushed in order. flutter-unity-view-widget drops these messages. - -2. **Multi-engine support**: The Game Framework's registry/factory pattern allows multiple engine types (Unity, Unreal) to coexist. flutter-unity-view-widget is tightly coupled to Unity. - -3. **Bridge reliability**: Using Objective-C runtime (`NSClassFromString`) instead of `dlsym` for iOS bridge is more reliable across framework loading scenarios. - -4. **Structured messaging**: The Game Framework supports structured messages with target/method/data, binary messages, batch messages, and automatic routing via `FlutterMonoBehaviour`. - -5. **CLI tooling**: The `game-cli` provides automated export, sync, and publish workflows. flutter-unity-view-widget requires manual Unity builds and file copying. - -6. **Platform expansion**: The Game Framework's architecture made it straightforward to add WebGL and macOS support using the same patterns established for iOS and Android. - -### What We Chose Differently - -1. **Distribution model for web**: flutter-unity-view-widget doesn't address web builds. The Game Framework uses an artifact-based distribution model where WebGL builds are uploaded to a CDN and loaded at runtime, keeping the Flutter package size manageable. - -2. **macOS as integrated framework**: The Game Framework treats macOS the same as iOS (framework bundled with the app), rather than requiring a separate runtime download. - -3. **Build automation**: Instead of requiring developers to manually configure Unity builds, the Game Framework's `FlutterBuildScript.cs` provides CLI-compatible build methods that the `game-cli` can invoke automatically. - -## Conclusion - -The flutter-unity-view-widget validated several core patterns for Unity-Flutter integration. The Game Framework builds on these patterns with a more robust, extensible architecture that supports multiple engines, platforms, and distribution models. The unified API (`GameEngineController`) makes it possible to swap engines without changing application code, while the `game-cli` tooling reduces the operational burden of managing Unity builds.