From c53e0ec59c00ce30e5f828edbd670e29d61a03a6 Mon Sep 17 00:00:00 2001 From: Xurshid Muhammadiyev Date: Mon, 3 Aug 2026 23:59:22 +0500 Subject: [PATCH 01/13] feat(android): import picked games into private library --- Assets/Scripts/Services/GameImportResult.cs | 44 +++++++++++ .../Scripts/Services/GameImportResult.cs.meta | 2 + Assets/Scripts/Services/GameImportService.cs | 76 +++++++++++++++++++ .../Services/GameImportService.cs.meta | 2 + .../Scripts/UI/GameListDocumentController.cs | 30 +++++++- 5 files changed, 150 insertions(+), 4 deletions(-) create mode 100644 Assets/Scripts/Services/GameImportResult.cs create mode 100644 Assets/Scripts/Services/GameImportResult.cs.meta create mode 100644 Assets/Scripts/Services/GameImportService.cs create mode 100644 Assets/Scripts/Services/GameImportService.cs.meta diff --git a/Assets/Scripts/Services/GameImportResult.cs b/Assets/Scripts/Services/GameImportResult.cs new file mode 100644 index 0000000..b019794 --- /dev/null +++ b/Assets/Scripts/Services/GameImportResult.cs @@ -0,0 +1,44 @@ +/* + * (C) 2023 Radrat Softworks + * + * Licensed under the Apache License, Version 2.0 (the "License"); + */ + +using System; + +namespace Nofun.Services +{ + public enum GameImportErrorCode + { + None, + SourceUnavailable, + PermissionDenied, + EmptyFile, + CopyFailed + } + + public readonly struct GameImportResult + { + public bool Succeeded { get; } + public string ImportedPath { get; } + public GameImportErrorCode ErrorCode { get; } + public string Message { get; } + public Exception Exception { get; } + + private GameImportResult(bool succeeded, string importedPath, GameImportErrorCode errorCode, + string message, Exception exception) + { + Succeeded = succeeded; + ImportedPath = importedPath; + ErrorCode = errorCode; + Message = message; + Exception = exception; + } + + public static GameImportResult Success(string importedPath) => + new(true, importedPath, GameImportErrorCode.None, null, null); + + public static GameImportResult Failure(GameImportErrorCode errorCode, string message, Exception exception = null) => + new(false, null, errorCode, message, exception); + } +} diff --git a/Assets/Scripts/Services/GameImportResult.cs.meta b/Assets/Scripts/Services/GameImportResult.cs.meta new file mode 100644 index 0000000..a420ae6 --- /dev/null +++ b/Assets/Scripts/Services/GameImportResult.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 71fdc1a57b244ebca9ebad4d701c714e diff --git a/Assets/Scripts/Services/GameImportService.cs b/Assets/Scripts/Services/GameImportService.cs new file mode 100644 index 0000000..c001d4c --- /dev/null +++ b/Assets/Scripts/Services/GameImportService.cs @@ -0,0 +1,76 @@ +/* + * (C) 2023 Radrat Softworks + * + * Licensed under the Apache License, Version 2.0 (the "License"); + */ + +using System; +using System.IO; + +namespace Nofun.Services +{ + public interface IGameImportService + { + GameImportResult Import(string sourcePath, string destinationPath); + } + + public sealed class GameImportService : IGameImportService + { + public GameImportResult Import(string sourcePath, string destinationPath) + { + if (string.IsNullOrWhiteSpace(sourcePath) || !File.Exists(sourcePath)) + { + return GameImportResult.Failure(GameImportErrorCode.SourceUnavailable, + "The selected game is no longer available."); + } + + try + { + var sourceInfo = new FileInfo(sourcePath); + if (sourceInfo.Length == 0) + { + return GameImportResult.Failure(GameImportErrorCode.EmptyFile, + "The selected game file is empty."); + } + + Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)); + string temporaryPath = destinationPath + ".importing"; + + try + { + using (var source = new FileStream(sourcePath, FileMode.Open, FileAccess.Read, FileShare.Read)) + using (var destination = new FileStream(temporaryPath, FileMode.Create, FileAccess.Write, FileShare.None)) + { + source.CopyTo(destination); + destination.Flush(); + } + + if (File.Exists(destinationPath)) + { + File.Delete(destinationPath); + } + + File.Move(temporaryPath, destinationPath); + return GameImportResult.Success(destinationPath); + } + finally + { + if (File.Exists(temporaryPath)) + { + File.Delete(temporaryPath); + } + } + } + catch (UnauthorizedAccessException ex) + { + return GameImportResult.Failure(GameImportErrorCode.PermissionDenied, + "Onlyfun does not have permission to read the selected file.", ex); + } + catch (Exception ex) + { + return GameImportResult.Failure(GameImportErrorCode.CopyFailed, + "Onlyfun could not copy the selected game into its library.", ex); + } + } + } +} diff --git a/Assets/Scripts/Services/GameImportService.cs.meta b/Assets/Scripts/Services/GameImportService.cs.meta new file mode 100644 index 0000000..a76ae4d --- /dev/null +++ b/Assets/Scripts/Services/GameImportService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bf53e117783446f4b140aef7fb91e54b diff --git a/Assets/Scripts/UI/GameListDocumentController.cs b/Assets/Scripts/UI/GameListDocumentController.cs index d622480..c3fa336 100644 --- a/Assets/Scripts/UI/GameListDocumentController.cs +++ b/Assets/Scripts/UI/GameListDocumentController.cs @@ -54,6 +54,7 @@ public class GameListDocumentController : FlexibleUIDocumentController, IGamePro [Inject] private IDialogService dialogService; [Inject] private ILayoutService layoutService; private DynamicIconsProvider dynamicIconsProvider; + private IGameImportService gameImportService; private string GamePathRoot => $"{Application.persistentDataPath}/__Games"; @@ -79,6 +80,7 @@ public override void Awake() } gameDatabase = new GameDatabase(GameDatabasePath); + gameImportService = new GameImportService(); dynamicIconsProvider = new DynamicIconsProvider(dynamicIconRendererContainer); Directory.CreateDirectory(GamePathRoot); @@ -211,7 +213,7 @@ private void InstallGame(string path) { try { - VMGPExecutable executable = new VMGPExecutable(executableFile); + using VMGPExecutable executable = new VMGPExecutable(executableFile); VMMetaInfoReader metaInfoReader = executable.GetMetaInfo(); if (metaInfoReader == null) @@ -272,9 +274,21 @@ private void InstallGame(string path) } else { - // Save the game into the persistent data folder string gamePath = GetGamePath(gameInfo); - File.Copy(path, gamePath, true); + GameImportResult importResult = gameImportService.Import(path, gamePath); + if (!importResult.Succeeded) + { + gameDatabase.RemoveGame(gameInfo); + Util.Logging.Logger.Error(Util.Logging.LogClass.Loader, + $"Game import failed ({importResult.ErrorCode}): {importResult.Message}\n{importResult.Exception}"); + + dialogService.Show(Severity.Error, + ButtonType.OK, + translationService.Translate("Error"), + importResult.Message, + null); + return; + } dialogService.Show(Severity.Info, ButtonType.OK, @@ -287,6 +301,8 @@ private void InstallGame(string path) } catch (Exception ex) { + Util.Logging.Logger.Error(Util.Logging.LogClass.Loader, + $"Game metadata parsing failed for selected import: {ex}"); dialogService.Show(Severity.Error, ButtonType.OK, translationService.Translate("Error"), @@ -323,7 +339,13 @@ private void OnInstallButtonClicked() if (!permissionGranted) { - Debug.Log("Todo: Show error message not granted"); + Util.Logging.Logger.Error(Util.Logging.LogClass.Loader, + "The system file picker did not grant access to the selected game."); + dialogService.Show(Severity.Error, + ButtonType.OK, + translationService.Translate("Error"), + "Onlyfun could not access the selected file. Please choose it again.", + null); } } From e11979ed1bf2d06c47eed80339779c8811a0bc78 Mon Sep 17 00:00:00 2001 From: Xurshid Muhammadiyev Date: Mon, 3 Aug 2026 23:59:33 +0500 Subject: [PATCH 02/13] feat(settings): resolve Honey Cave 2 legacy profile --- .../Scripts/Settings/GameProfileResolver.cs | 76 +++++++++++++++++++ .../Settings/GameProfileResolver.cs.meta | 2 + .../Scripts/Settings/GameSettingsManager.cs | 14 +++- .../UI/GameDetailsDocumentController.cs | 3 +- Assets/Scripts/VM/VMSystem.cs | 45 +---------- 5 files changed, 94 insertions(+), 46 deletions(-) create mode 100644 Assets/Scripts/Settings/GameProfileResolver.cs create mode 100644 Assets/Scripts/Settings/GameProfileResolver.cs.meta diff --git a/Assets/Scripts/Settings/GameProfileResolver.cs b/Assets/Scripts/Settings/GameProfileResolver.cs new file mode 100644 index 0000000..3657996 --- /dev/null +++ b/Assets/Scripts/Settings/GameProfileResolver.cs @@ -0,0 +1,76 @@ +/* + * (C) 2023 Radrat Softworks + * + * Licensed under the Apache License, Version 2.0 (the "License"); + */ + +using System; +using Nofun.Module.VMGPCaps; +using Nofun.Parser; + +namespace Nofun.Settings +{ + public static class GameProfileResolver + { + public static GameSetting Resolve(string title, VMGPExecutable executable) + { + if (IsHoneyCave2(title)) + { + return Legacy2DProfile(); + } + + return IsNewGenerationGame(executable) ? NewGenerationProfile() : Legacy2DProfile(); + } + + public static bool IsHoneyCave2(string title) + { + if (string.IsNullOrWhiteSpace(title)) + { + return false; + } + + string normalized = title.Replace(" ", string.Empty); + return normalized.Equals("HoneyCave2", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsNewGenerationGame(VMGPExecutable executable) + { + foreach (var poolItem in executable.PoolItems) + { + if (poolItem.poolType == PoolItemType.ImportSymbol && + executable.GetString(poolItem.metaOffset) == "vInit3D") + { + return true; + } + } + + return false; + } + + private static GameSetting Legacy2DProfile() => new() + { + screenSizeX = 101, + screenSizeY = 80, + fps = 15, + screenMode = ScreenMode.CustomSize, + orientation = ScreenOrientation.Potrait, + deviceModel = SystemDeviceModel.SonyEricssonT310, + systemVersion = SystemVersion.Version130, + cpuBackend = CPUBackend.Interpreter, + enableSoftwareScissor = false + }; + + private static GameSetting NewGenerationProfile() => new() + { + screenSizeX = 240, + screenSizeY = 320, + fps = 60, + screenMode = ScreenMode.CustomSize, + orientation = ScreenOrientation.Potrait, + deviceModel = SystemDeviceModel.NokiaNgage, + systemVersion = SystemVersion.Version150, + cpuBackend = CPUBackend.Interpreter, + enableSoftwareScissor = false + }; + } +} diff --git a/Assets/Scripts/Settings/GameProfileResolver.cs.meta b/Assets/Scripts/Settings/GameProfileResolver.cs.meta new file mode 100644 index 0000000..3c15658 --- /dev/null +++ b/Assets/Scripts/Settings/GameProfileResolver.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: da601301a57b4cb09ad195691b13674f diff --git a/Assets/Scripts/Settings/GameSettingsManager.cs b/Assets/Scripts/Settings/GameSettingsManager.cs index 9af8aed..7802e59 100644 --- a/Assets/Scripts/Settings/GameSettingsManager.cs +++ b/Assets/Scripts/Settings/GameSettingsManager.cs @@ -44,7 +44,19 @@ private string GetSettingPath(string gameName) return null; } - return JsonUtility.FromJson(File.ReadAllText(gameSettingPath)); + GameSetting setting = JsonUtility.FromJson(File.ReadAllText(gameSettingPath)); + + // Early builds saved the 3D defaults for Honey Cave 2. Discard that known-bad + // value so the resolver can restore the correct legacy profile. + if (GameProfileResolver.IsHoneyCave2(gameName) && + (setting.screenSizeX != 101 || setting.screenSizeY != 80 || + setting.systemVersion != SystemVersion.Version130)) + { + File.Delete(gameSettingPath); + return null; + } + + return setting; } public bool Set(string gameName, GameSetting setting) diff --git a/Assets/Scripts/UI/GameDetailsDocumentController.cs b/Assets/Scripts/UI/GameDetailsDocumentController.cs index fc71ab7..a7313bd 100644 --- a/Assets/Scripts/UI/GameDetailsDocumentController.cs +++ b/Assets/Scripts/UI/GameDetailsDocumentController.cs @@ -94,7 +94,8 @@ private void OnSettingButtonClicked(PointerUpEvent evt) { VMGPExecutable executable = new VMGPExecutable(stream); - settingDocumentController.Setup(gameSettingsManager, activeGameInfo.Name, VMSystem.GetSuitableDefaultSetting(executable)); + settingDocumentController.Setup(gameSettingsManager, activeGameInfo.Name, + GameProfileResolver.Resolve(activeGameInfo.Name, executable)); settingDocumentController.Show(); } } diff --git a/Assets/Scripts/VM/VMSystem.cs b/Assets/Scripts/VM/VMSystem.cs index aa39592..4f9af73 100644 --- a/Assets/Scripts/VM/VMSystem.cs +++ b/Assets/Scripts/VM/VMSystem.cs @@ -315,50 +315,7 @@ public void Dispose() public static GameSetting GetSuitableDefaultSetting(VMGPExecutable executable) { - bool IsNewGenerationGame() - { - var poolItems = executable.PoolItems; - foreach (var poolItem in poolItems) - { - if (poolItem.poolType == PoolItemType.ImportSymbol) - { - var importName = executable.GetString(poolItem.metaOffset); - if (importName == "vInit3D") - { - return true; - } - } - } - - return false; - } - - if (IsNewGenerationGame()) - { - return new GameSetting() - { - screenSizeX = 240, - screenSizeY = 320, - fps = 60, - screenMode = ScreenMode.CustomSize, - deviceModel = Module.VMGPCaps.SystemDeviceModel.NokiaNgage, - systemVersion = SystemVersion.Version150, - enableSoftwareScissor = false - }; - } - else - { - return new GameSetting() - { - screenSizeX = 101, - screenSizeY = 80, - fps = 15, - screenMode = ScreenMode.CustomSize, - deviceModel = Module.VMGPCaps.SystemDeviceModel.SonyEricssonT310, - systemVersion = SystemVersion.Version130, - enableSoftwareScissor = false - }; - } + return GameProfileResolver.Resolve(null, executable); } public bool ShouldStop => shouldStop; From a4502eee47c8ca977f04eb1ad56e6452e8fa6850 Mon Sep 17 00:00:00 2001 From: Xurshid Muhammadiyev Date: Mon, 3 Aug 2026 23:59:42 +0500 Subject: [PATCH 03/13] fix(runner): recover from load errors without quitting --- Assets/Scripts/NofunRunner.cs | 48 ++++++++---- .../Util.Unity/Logging/FileLogTarget.cs | 74 +++++++++++++++++++ .../Util.Unity/Logging/FileLogTarget.cs.meta | 2 + Docs/AndroidSmokeTest.md | 22 ++++++ 4 files changed, 132 insertions(+), 14 deletions(-) create mode 100644 Assets/Scripts/Util.Unity/Logging/FileLogTarget.cs create mode 100644 Assets/Scripts/Util.Unity/Logging/FileLogTarget.cs.meta create mode 100644 Docs/AndroidSmokeTest.md diff --git a/Assets/Scripts/NofunRunner.cs b/Assets/Scripts/NofunRunner.cs index 307eb58..0321099 100644 --- a/Assets/Scripts/NofunRunner.cs +++ b/Assets/Scripts/NofunRunner.cs @@ -72,6 +72,7 @@ public class NofunRunner : MonoBehaviour private bool llvmPrepared = false; private int llvmPreparingDialogId = -1; + private static FileLogTarget fileLogTarget; [Inject] private ScreenManager screenManager; [Inject] private IDialogService dialogService; @@ -95,6 +96,11 @@ public void Construct(ScreenManager injectScreenManager) private void SetupLogger() { Util.Logging.Logger.AddTarget(new UnityLogTarget()); + if (fileLogTarget == null) + { + fileLogTarget = new FileLogTarget(Application.persistentDataPath); + Util.Logging.Logger.AddTarget(fileLogTarget); + } } private void OnDestroy() @@ -187,14 +193,9 @@ private void Start() #if !UNITY_EDITOR && NOFUN_PRODUCTION #if UNITY_ANDROID - try - { - gameStream = new MophunAndroidFileStream(); - } - catch (System.Exception _) - { - return; - } + // Normal Android launches always open the library. Imports are copied to private + // storage by GameImportService before the emulator sees them. + return; #else string[] cmdLines = System.Environment.GetCommandLineArgs(); @@ -235,7 +236,7 @@ public void Launch(string gamePath) executableFilePath = gamePath; launchRequested = true; - FileStream stream = new FileStream(gamePath, FileMode.Open, FileAccess.ReadWrite, FileShare.Read); + FileStream stream = new FileStream(gamePath, FileMode.Open, FileAccess.Read, FileShare.Read); StartGameImpl(stream, gamePath); } @@ -279,19 +280,38 @@ public void StartGameImpl(Stream gameStream, string targetExecutable) system = new VMSystem(executable, new VMSystemCreateParameters(graphicDriver, inputDriver, audioDriver, timeDriver, uiDriver, Application.persistentDataPath, targetExecutable, enableLLVM)); } - catch (System.Exception _) + catch (System.Exception ex) { - dialogService.Show(Severity.Info, ButtonType.OK, + Util.Logging.Logger.Error(Util.Logging.LogClass.Loader, + $"Game load failed during executable/VM creation: {ex}"); + + system?.Dispose(); + system = null; + + if (executable != null) + { + executable.Dispose(); + executable = null; + } + else + { + gameStream?.Dispose(); + } + + dialogService.Show(Severity.Error, ButtonType.OK, null, translationService.Translate("Error_Description_GameNotCompatible"), - value => Application.Quit()); + null); failed = true; + launchRequested = false; + gameListDocumentController.ImmediateShow(); return; } - settingDocument.Setup(settingManager, system.GameName, VMSystem.GetSuitableDefaultSetting(system.Executable)); + settingDocument.Setup(settingManager, system.GameName, + GameProfileResolver.Resolve(system.GameName, system.Executable)); settingDocument.Finished += FinishSettingDocument; settingDocument.ExitGameRequested += HandleExitGame; @@ -335,7 +355,7 @@ public void StartGameImpl(Stream gameStream, string targetExecutable) private IEnumerator InitializeGameRun() { GameSetting? setting = settingManager.Get(system.GameName); - setting = setting ?? VMSystem.GetSuitableDefaultSetting(system.Executable); + setting = setting ?? GameProfileResolver.Resolve(system.GameName, system.Executable); system.GameSetting = setting.Value; diff --git a/Assets/Scripts/Util.Unity/Logging/FileLogTarget.cs b/Assets/Scripts/Util.Unity/Logging/FileLogTarget.cs new file mode 100644 index 0000000..29664c4 --- /dev/null +++ b/Assets/Scripts/Util.Unity/Logging/FileLogTarget.cs @@ -0,0 +1,74 @@ +/* + * (C) 2023 Radrat Softworks + * + * Licensed under the Apache License, Version 2.0 (the "License"); + */ + +using System; +using System.IO; +using Nofun.Util.Logging; +using UnityEngine; + +namespace Nofun.Util.Unity +{ + public sealed class FileLogTarget : ILogTarget + { + private const long MaximumLogSize = 1024 * 1024; + private readonly object writeLock = new(); + private readonly string logPath; + + public FileLogTarget(string persistentDataPath) + { + logPath = Path.Combine(persistentDataPath, "onlyfun.log"); + TryRotate(); + } + + public string Name => "Onlyfun file"; + public string LogPath => logPath; + + public void Log(object sender, LogEventArgs args) + { + lock (writeLock) + { + try + { + RotateIfNeeded(); + File.AppendAllText(logPath, + $"{args.time:O} [{args.logLevel}] [{args.logClass}] {args.message}{Environment.NewLine}"); + } + catch (Exception ex) + { + Debug.LogError($"Onlyfun could not write its diagnostic log: {ex}"); + } + } + } + + private void TryRotate() + { + try + { + RotateIfNeeded(); + } + catch (Exception ex) + { + Debug.LogError($"Onlyfun could not rotate its diagnostic log: {ex}"); + } + } + + private void RotateIfNeeded() + { + if (!File.Exists(logPath) || new FileInfo(logPath).Length < MaximumLogSize) + { + return; + } + + string previousPath = logPath + ".1"; + if (File.Exists(previousPath)) + { + File.Delete(previousPath); + } + + File.Move(logPath, previousPath); + } + } +} diff --git a/Assets/Scripts/Util.Unity/Logging/FileLogTarget.cs.meta b/Assets/Scripts/Util.Unity/Logging/FileLogTarget.cs.meta new file mode 100644 index 0000000..a211d7f --- /dev/null +++ b/Assets/Scripts/Util.Unity/Logging/FileLogTarget.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3d54cc9803a846cf81639441733280c7 diff --git a/Docs/AndroidSmokeTest.md b/Docs/AndroidSmokeTest.md new file mode 100644 index 0000000..d398be3 --- /dev/null +++ b/Docs/AndroidSmokeTest.md @@ -0,0 +1,22 @@ +# Onlyfun Android smoke test + +This checklist covers the first Android reliability increment. It does not require a +commercial game to be checked into the repository. + +1. Build and install a development APK, then launch it from the Android launcher. +2. Confirm that the game library appears and that the application does not ask to be + opened through a file manager. +3. Tap **Install**, select `HoneyCave2.mpn` in the stock Android Files picker, and + confirm that the game appears in the library. +4. Disconnect or remove the original source file and confirm that the imported game + remains available. The private copy is stored under `persistentDataPath/__Games`. +5. Open the game's settings and confirm `101x80`, portrait, Sony Ericsson T310, + Mophun 1.30, interpreter, and 15 FPS. +6. Start the game. If loading fails, confirm that an error is shown, the app stays + open, and the library can be used again. +7. Collect `onlyfun.log` from the application's persistent data directory and confirm + that it contains the exception type, message, stack trace, and loading context. + +Known scope limitation: external `ACTION_VIEW`, `ACTION_SEND`, and `onNewIntent` +imports are planned for the next Android lifecycle increment. This checklist tests +the in-app system picker path. From 19b124e96cdab2004d84190c3f4476b4a4ea88c2 Mon Sep 17 00:00:00 2001 From: Xurshid Muhammadiyev Date: Tue, 4 Aug 2026 00:00:45 +0500 Subject: [PATCH 04/13] test: cover private import and Honey Cave profile --- Assets/Tests.meta | 8 ++ Assets/Tests/Editor.meta | 8 ++ .../Tests/Editor/OnlyfunReliabilityTests.cs | 76 +++++++++++++++++++ .../Editor/OnlyfunReliabilityTests.cs.meta | 2 + 4 files changed, 94 insertions(+) create mode 100644 Assets/Tests.meta create mode 100644 Assets/Tests/Editor.meta create mode 100644 Assets/Tests/Editor/OnlyfunReliabilityTests.cs create mode 100644 Assets/Tests/Editor/OnlyfunReliabilityTests.cs.meta diff --git a/Assets/Tests.meta b/Assets/Tests.meta new file mode 100644 index 0000000..c3d4af6 --- /dev/null +++ b/Assets/Tests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 2ca338841f36466bb82f7d0f868f211f +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor.meta b/Assets/Tests/Editor.meta new file mode 100644 index 0000000..4df2e9c --- /dev/null +++ b/Assets/Tests/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a3218fd84fd84bf3bec1816bf5915116 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/OnlyfunReliabilityTests.cs b/Assets/Tests/Editor/OnlyfunReliabilityTests.cs new file mode 100644 index 0000000..3f22b8b --- /dev/null +++ b/Assets/Tests/Editor/OnlyfunReliabilityTests.cs @@ -0,0 +1,76 @@ +/* + * (C) 2023 Radrat Softworks + * + * Licensed under the Apache License, Version 2.0 (the "License"); + */ + +using System.IO; +using Nofun.Module.VMGPCaps; +using Nofun.Services; +using Nofun.Settings; +using NUnit.Framework; + +namespace Nofun.Tests +{ + public class OnlyfunReliabilityTests + { + private string temporaryDirectory; + + [SetUp] + public void SetUp() + { + temporaryDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(temporaryDirectory); + } + + [TearDown] + public void TearDown() + { + Directory.Delete(temporaryDirectory, true); + } + + [TestCase("HoneyCave2")] + [TestCase("Honey Cave 2")] + [TestCase("honey cave 2")] + public void HoneyCave2GetsExactLegacyProfile(string title) + { + GameSetting profile = GameProfileResolver.Resolve(title, null); + + Assert.That(profile.screenSizeX, Is.EqualTo(101)); + Assert.That(profile.screenSizeY, Is.EqualTo(80)); + Assert.That(profile.orientation, Is.EqualTo(ScreenOrientation.Potrait)); + Assert.That(profile.deviceModel, Is.EqualTo(SystemDeviceModel.SonyEricssonT310)); + Assert.That(profile.systemVersion, Is.EqualTo(SystemVersion.Version130)); + Assert.That(profile.cpuBackend, Is.EqualTo(CPUBackend.Interpreter)); + Assert.That(profile.fps, Is.EqualTo(15)); + } + + [Test] + public void ImportCopiesGameToPrivateDestination() + { + string source = Path.Combine(temporaryDirectory, "picked.mpn"); + string destination = Path.Combine(temporaryDirectory, "__Games", "00000001.mpn"); + byte[] contents = { 1, 2, 3, 4 }; + File.WriteAllBytes(source, contents); + + GameImportResult result = new GameImportService().Import(source, destination); + + Assert.That(result.Succeeded, Is.True); + Assert.That(result.ImportedPath, Is.EqualTo(destination)); + Assert.That(File.ReadAllBytes(destination), Is.EqualTo(contents)); + } + + [Test] + public void ImportRejectsEmptyGameWithTypedError() + { + string source = Path.Combine(temporaryDirectory, "empty.mpn"); + File.WriteAllBytes(source, new byte[0]); + + GameImportResult result = new GameImportService().Import(source, + Path.Combine(temporaryDirectory, "__Games", "00000001.mpn")); + + Assert.That(result.Succeeded, Is.False); + Assert.That(result.ErrorCode, Is.EqualTo(GameImportErrorCode.EmptyFile)); + } + } +} diff --git a/Assets/Tests/Editor/OnlyfunReliabilityTests.cs.meta b/Assets/Tests/Editor/OnlyfunReliabilityTests.cs.meta new file mode 100644 index 0000000..4b0f3bc --- /dev/null +++ b/Assets/Tests/Editor/OnlyfunReliabilityTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d36510668922417ea77f5ca2e225c82a From ae5a0773e1b30d492aa4f1930f07bf5bf0c6c739 Mon Sep 17 00:00:00 2001 From: Xurshid Muhammadiyev Date: Tue, 4 Aug 2026 00:01:15 +0500 Subject: [PATCH 05/13] chore: normalize Unity test metadata --- Assets/Tests.meta | 6 +++--- Assets/Tests/Editor.meta | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Assets/Tests.meta b/Assets/Tests.meta index c3d4af6..f3d2c1a 100644 --- a/Assets/Tests.meta +++ b/Assets/Tests.meta @@ -3,6 +3,6 @@ guid: 2ca338841f36466bb82f7d0f868f211f folderAsset: yes DefaultImporter: externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor.meta b/Assets/Tests/Editor.meta index 4df2e9c..c9a5480 100644 --- a/Assets/Tests/Editor.meta +++ b/Assets/Tests/Editor.meta @@ -3,6 +3,6 @@ guid: a3218fd84fd84bf3bec1816bf5915116 folderAsset: yes DefaultImporter: externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: + userData: + assetBundleName: + assetBundleVariant: From 1d3a7a0128aaa84b406495d4fe525deebcda5e98 Mon Sep 17 00:00:00 2001 From: Xurshid Muhammadiyev Date: Tue, 4 Aug 2026 00:44:05 +0500 Subject: [PATCH 06/13] fix(android): harden import and VM recovery --- Assets/Scripts/NofunRunner.cs | 262 +++++++++++++----- .../Scripts/PIP2/Interpreter/Interpreter.cs | 2 +- .../Scripts/Settings/GameSettingsManager.cs | 4 +- .../Scripts/UI/GameListDocumentController.cs | 131 +++++---- Assets/Scripts/VM/VMSystem.cs | 4 +- Assets/Tests/Editor/Onlyfun.Tests.asmdef | 19 ++ Assets/Tests/Editor/Onlyfun.Tests.asmdef.meta | 2 + .../Tests/Editor/OnlyfunReliabilityTests.cs | 96 +++++-- 8 files changed, 377 insertions(+), 143 deletions(-) create mode 100644 Assets/Tests/Editor/Onlyfun.Tests.asmdef create mode 100644 Assets/Tests/Editor/Onlyfun.Tests.asmdef.meta diff --git a/Assets/Scripts/NofunRunner.cs b/Assets/Scripts/NofunRunner.cs index 0321099..1e091e1 100644 --- a/Assets/Scripts/NofunRunner.cs +++ b/Assets/Scripts/NofunRunner.cs @@ -54,6 +54,7 @@ public class NofunRunner : MonoBehaviour [SerializeField] private GameDetailsDocumentController gameDetailsDocument; [SerializeField] private GameListDocumentController gameListDocumentController; [SerializeField] private float waitTimeBeforeNotifyUserOfLLVM = 0.2f; + [SerializeField] private float llvmPreparationTimeout = 30.0f; [Header("Settings")] [Range(1, 60)][SerializeField] private int fpsLimit = 30; @@ -70,9 +71,10 @@ public class NofunRunner : MonoBehaviour private bool settingActive = false; private bool launchRequested = false; - private bool llvmPrepared = false; + private volatile bool llvmPrepared = false; private int llvmPreparingDialogId = -1; private static FileLogTarget fileLogTarget; + private volatile bool isDestroying = false; [Inject] private ScreenManager screenManager; [Inject] private IDialogService dialogService; @@ -105,12 +107,12 @@ private void SetupLogger() private void OnDestroy() { + isDestroying = true; settingDocument.Finished -= FinishSettingDocument; settingDocument.ExitGameRequested -= HandleExitGame; - - if (system != null) + if (StopAndJoinSystemThread()) { - system.Stop(); + Reset(); } } @@ -154,7 +156,7 @@ private void HandleExitGame() settingActive = false; JobScheduler.Paused = false; - system.Stop(); + system?.Stop(); } private void OpenGameSetting() @@ -216,38 +218,57 @@ private void Start() #endif #if UNITY_EDITOR || !UNITY_ANDROID - gameStream = new FileStream(targetExecutable, FileMode.Open, FileAccess.ReadWrite, + gameStream = new FileStream(targetExecutable, FileMode.Open, FileAccess.Read, FileShare.Read); #endif - gameListDocumentController.ImmediateHide(); launchRequested = true; - StartGameImpl(gameStream, targetExecutable); + if (StartGameImpl(gameStream, targetExecutable)) + { + gameListDocumentController.ImmediateHide(); + } #if UNITY_EDITOR } #endif } - public void Launch(string gamePath) + public bool Launch(string gamePath) { + if (!StopAndJoinSystemThread()) + { + dialogService.Show(Severity.Error, ButtonType.OK, + null, + "The previous game is still stopping. Please try again.", + null); + gameListDocumentController.ImmediateShow(); + return false; + } + Reset(); executableFilePath = gamePath; launchRequested = true; - FileStream stream = new FileStream(gamePath, FileMode.Open, FileAccess.Read, FileShare.Read); - StartGameImpl(stream, gamePath); + try + { + FileStream stream = new FileStream(gamePath, FileMode.Open, FileAccess.Read, FileShare.Read); + return StartGameImpl(stream, gamePath); + } + catch (System.Exception ex) + { + HandleLoadFailure(null, ex, "opening the private game file"); + return false; + } } private void Reset() { - bool shouldGc = false; + settingDocument.Finished -= FinishSettingDocument; + settingDocument.ExitGameRequested -= HandleExitGame; if (system != null) { - shouldGc = true; - system.Dispose(); system = null; } @@ -262,29 +283,42 @@ private void Reset() executable = null; } - if (shouldGc) - { - GC.Collect(); - } - #if UNITY_STANDALONE_WIN && !UNITY_EDITOR SetWindowText(currentWindow, $"nofun"); #endif } - public void StartGameImpl(Stream gameStream, string targetExecutable) + private bool StopAndJoinSystemThread() { try { - executable = new VMGPExecutable(gameStream); - system = new VMSystem(executable, new VMSystemCreateParameters(graphicDriver, inputDriver, audioDriver, timeDriver, uiDriver, - Application.persistentDataPath, targetExecutable, enableLLVM)); + system?.Stop(); } catch (System.Exception ex) { Util.Logging.Logger.Error(Util.Logging.LogClass.Loader, - $"Game load failed during executable/VM creation: {ex}"); + $"Requesting VM worker stop failed: {ex}"); + } + + if (systemThread != null && systemThread.IsAlive && Thread.CurrentThread != systemThread && + !systemThread.Join(2000)) + { + Util.Logging.Logger.Error(Util.Logging.LogClass.Loader, + "VM worker did not stop within two seconds; its resources were left intact."); + return false; + } + + systemThread = null; + return true; + } + + private void HandleLoadFailure(Stream gameStream, System.Exception ex, string stage) + { + Util.Logging.Logger.Error(Util.Logging.LogClass.Loader, + $"Game load failed while {stage}: {ex}"); + try + { system?.Dispose(); system = null; @@ -297,86 +331,157 @@ public void StartGameImpl(Stream gameStream, string targetExecutable) { gameStream?.Dispose(); } - - dialogService.Show(Severity.Error, ButtonType.OK, - null, - translationService.Translate("Error_Description_GameNotCompatible"), - null); - - failed = true; - launchRequested = false; - gameListDocumentController.ImmediateShow(); - - return; + } + catch (System.Exception cleanupException) + { + Util.Logging.Logger.Error(Util.Logging.LogClass.Loader, + $"Game cleanup failed after the original load error: {cleanupException}"); } - settingDocument.Setup(settingManager, system.GameName, - GameProfileResolver.Resolve(system.GameName, system.Executable)); + failed = true; + launchRequested = false; + settingActive = false; + JobScheduler.Paused = false; + settingDocument.Finished -= FinishSettingDocument; + settingDocument.ExitGameRequested -= HandleExitGame; + gameListDocumentController.ImmediateShow(); - settingDocument.Finished += FinishSettingDocument; - settingDocument.ExitGameRequested += HandleExitGame; + dialogService.Show(Severity.Error, ButtonType.OK, + null, + translationService.Translate("Error_Description_GameNotCompatible"), + null); + } - if (settingManager.Get(system.GameName) == null) + public bool StartGameImpl(Stream gameStream, string targetExecutable) + { + try { - OpenGameSetting(); - } + executable = new VMGPExecutable(gameStream); + system = new VMSystem(executable, new VMSystemCreateParameters(graphicDriver, inputDriver, audioDriver, timeDriver, uiDriver, + Application.persistentDataPath, targetExecutable, enableLLVM)); - systemThread = new Thread(new ThreadStart(() => - { - system.PostInitialize(); - llvmPrepared = true; + settingDocument.Setup(settingManager, system.GameName, + GameProfileResolver.Resolve(system.GameName, system.Executable)); + + settingDocument.Finished -= FinishSettingDocument; + settingDocument.ExitGameRequested -= HandleExitGame; + settingDocument.Finished += FinishSettingDocument; + settingDocument.ExitGameRequested += HandleExitGame; - while (!system.ShouldStop) + if (settingManager.Get(system.GameName) == null) { + OpenGameSetting(); + } + + VMSystem runningSystem = system; + systemThread = new Thread(() => + { + System.Exception failure = null; try { - system.Run(); + runningSystem.PostInitialize(); + llvmPrepared = true; + + while (!runningSystem.ShouldStop) + { + runningSystem.Run(); + } } catch (System.Exception ex) { - Util.Logging.Logger.Error(Util.Logging.LogClass.Loader, $"System execution encounter exception: {ex}"); - break; + failure = ex; + Util.Logging.Logger.Error(Util.Logging.LogClass.Loader, + $"VM initialization or execution failed: {ex}"); } - } - - Reset(); - - JobScheduler.Instance.RunOnUnityThread(() => + finally + { + llvmPrepared = true; + if (!isDestroying) + { + JobScheduler.Instance.RunOnUnityThread(() => + HandleSystemThreadFinished(runningSystem, failure)); + } + } + }) { - StartCoroutine(ShowGameListDelay()); - }); - })); + IsBackground = true, + Name = "Onlyfun VM" + }; + } + catch (System.Exception ex) + { + HandleLoadFailure(gameStream, ex, "creating the executable and VM"); + return false; + } #if UNITY_STANDALONE_WIN && !UNITY_EDITOR currentWindow = GetActiveWindow(); #endif + return true; + } + + private void HandleSystemThreadFinished(VMSystem finishedSystem, System.Exception failure) + { + if (system != finishedSystem || isDestroying) + { + return; + } + + if (llvmPreparingDialogId >= 0) + { + dialogService.CloseBlocked(llvmPreparingDialogId); + llvmPreparingDialogId = -1; + } + + Reset(); + StartCoroutine(ShowGameListDelay()); + + if (failure != null) + { + dialogService.Show(Severity.Error, ButtonType.OK, + null, + translationService.Translate("Error_Description_GameNotCompatible"), + null); + } } private IEnumerator InitializeGameRun() { - GameSetting? setting = settingManager.Get(system.GameName); - setting = setting ?? GameProfileResolver.Resolve(system.GameName, system.Executable); + GameSetting setting; + try + { + GameSetting? storedSetting = settingManager.Get(system.GameName); + setting = storedSetting ?? GameProfileResolver.Resolve(system.GameName, system.Executable); - system.GameSetting = setting.Value; + system.GameSetting = setting; - // Change orientation first - screenManager.ScreenOrientation = setting.Value.orientation; + // Change orientation first + screenManager.ScreenOrientation = setting.orientation; - graphicDriver.Initialize((setting.Value.screenMode == ScreenMode.CustomSize) ? - new Vector2(setting.Value.screenSizeX, setting.Value.screenSizeY) : - Vector2.zero, setting.Value.enableSoftwareScissor); + graphicDriver.Initialize((setting.screenMode == ScreenMode.CustomSize) ? + new Vector2(setting.screenSizeX, setting.screenSizeY) : + Vector2.zero, setting.enableSoftwareScissor); - graphicDriver.FpsLimit = Mathf.Clamp(setting.Value.fps, 1, 120); - systemThread.Start(); + graphicDriver.FpsLimit = Mathf.Clamp(setting.fps, 1, 120); + if (setting.cpuBackend == CPUBackend.LLVM) + { + llvmPrepared = false; + llvmPreparingDialogId = -1; + } - if (setting.Value.cpuBackend == CPUBackend.LLVM) + systemThread.Start(); + } + catch (System.Exception ex) { - llvmPrepared = false; - llvmPreparingDialogId = -1; + HandleLoadFailure(null, ex, "initializing graphics and starting the VM worker"); + yield break; + } + if (setting.cpuBackend == CPUBackend.LLVM) + { float elapsedTime = 0.0f; - while (!llvmPrepared) + while (!llvmPrepared && elapsedTime < llvmPreparationTimeout) { if (waitTimeBeforeNotifyUserOfLLVM <= elapsedTime && llvmPreparingDialogId < 0) { @@ -390,9 +495,22 @@ private IEnumerator InitializeGameRun() elapsedTime += Time.deltaTime; } + if (!llvmPrepared) + { + Util.Logging.Logger.Error(Util.Logging.LogClass.Loader, + $"LLVM initialization exceeded {llvmPreparationTimeout:0.0} seconds."); + system?.Stop(); + launchRequested = false; + dialogService.Show(Severity.Error, ButtonType.OK, + null, + "LLVM initialization timed out. Onlyfun will return to the library.", + null); + } + if (llvmPreparingDialogId >= 0) { dialogService.CloseBlocked(llvmPreparingDialogId); + llvmPreparingDialogId = -1; } } diff --git a/Assets/Scripts/PIP2/Interpreter/Interpreter.cs b/Assets/Scripts/PIP2/Interpreter/Interpreter.cs index 014c617..0440786 100644 --- a/Assets/Scripts/PIP2/Interpreter/Interpreter.cs +++ b/Assets/Scripts/PIP2/Interpreter/Interpreter.cs @@ -23,7 +23,7 @@ public partial class Interpreter : Processor { private Action[] OpcodeTables; - private bool shouldStop = false; + private volatile bool shouldStop = false; private bool isRunning = false; private int instructionRan = 0; diff --git a/Assets/Scripts/Settings/GameSettingsManager.cs b/Assets/Scripts/Settings/GameSettingsManager.cs index 7802e59..9d939f5 100644 --- a/Assets/Scripts/Settings/GameSettingsManager.cs +++ b/Assets/Scripts/Settings/GameSettingsManager.cs @@ -49,8 +49,8 @@ private string GetSettingPath(string gameName) // Early builds saved the 3D defaults for Honey Cave 2. Discard that known-bad // value so the resolver can restore the correct legacy profile. if (GameProfileResolver.IsHoneyCave2(gameName) && - (setting.screenSizeX != 101 || setting.screenSizeY != 80 || - setting.systemVersion != SystemVersion.Version130)) + setting.screenSizeX == 240 && setting.screenSizeY == 320 && + setting.systemVersion == SystemVersion.Version150) { File.Delete(gameSettingPath); return null; diff --git a/Assets/Scripts/UI/GameListDocumentController.cs b/Assets/Scripts/UI/GameListDocumentController.cs index c3fa336..38e3e56 100644 --- a/Assets/Scripts/UI/GameListDocumentController.cs +++ b/Assets/Scripts/UI/GameListDocumentController.cs @@ -137,9 +137,10 @@ private void OnGameIconClicked(string gameFileName) } runner.gameObject.SetActive(true); - runner.Launch(gamePath); - - ImmediateHide(); + if (runner.Launch(gamePath)) + { + ImmediateHide(); + } } } @@ -209,12 +210,26 @@ private void InstallGame(string path) return; } - using (var executableFile = File.OpenRead(path)) + string stagedPath = Path.Combine(GamePathRoot, $".{Guid.NewGuid():N}.mpn"); + GameImportResult importResult = gameImportService.Import(path, stagedPath); + if (!importResult.Succeeded) { - try - { - using VMGPExecutable executable = new VMGPExecutable(executableFile); + Util.Logging.Logger.Error(Util.Logging.LogClass.Loader, + $"Game import failed ({importResult.ErrorCode}): {importResult.Message}\n{importResult.Exception}"); + dialogService.Show(Severity.Error, + ButtonType.OK, + translationService.Translate("Error"), + importResult.Message, + null); + return; + } + try + { + GameInfo gameInfo; + using (var executableFile = File.OpenRead(stagedPath)) + using (VMGPExecutable executable = new VMGPExecutable(executableFile)) + { VMMetaInfoReader metaInfoReader = executable.GetMetaInfo(); if (metaInfoReader == null) { @@ -252,62 +267,77 @@ private void InstallGame(string path) ? null : version.Split(".", StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToArray(); } - catch + catch (Exception ex) { + Util.Logging.Logger.Warning(Util.Logging.LogClass.Loader, + $"Game metadata contains an invalid version '{version}': {ex}"); versionNumbers = new[] { 0, 0, 0 }; } - GameInfo gameInfo = new GameInfo(titleName, vendor ?? null, + gameInfo = new GameInfo(titleName, vendor ?? null, versionNumbers != null && versionNumbers.Length >= 1 ? versionNumbers[0] : 0, versionNumbers != null && versionNumbers.Length >= 2 ? versionNumbers[1] : 0, versionNumbers != null && versionNumbers.Length >= 3 ? versionNumbers[2] : 0); + } - if (!gameDatabase.AddGame(gameInfo)) - { - dialogService.Show(Severity.Error, - ButtonType.OK, - translationService.Translate("Error"), - translationService.Translate("Error_Description_GameAlreadyInstalled"), - null); + if (!gameDatabase.AddGame(gameInfo)) + { + dialogService.Show(Severity.Error, + ButtonType.OK, + translationService.Translate("Error"), + translationService.Translate("Error_Description_GameAlreadyInstalled"), + null); - return; - } - else + return; + } + + string gamePath = GetGamePath(gameInfo); + try + { + if (File.Exists(gamePath)) { - string gamePath = GetGamePath(gameInfo); - GameImportResult importResult = gameImportService.Import(path, gamePath); - if (!importResult.Succeeded) - { - gameDatabase.RemoveGame(gameInfo); - Util.Logging.Logger.Error(Util.Logging.LogClass.Loader, - $"Game import failed ({importResult.ErrorCode}): {importResult.Message}\n{importResult.Exception}"); - - dialogService.Show(Severity.Error, - ButtonType.OK, - translationService.Translate("Error"), - importResult.Message, - null); - return; - } - - dialogService.Show(Severity.Info, - ButtonType.OK, - translationService.Translate("Success"), - translationService.Translate("Success_Description_Install"), - null); + File.Delete(gamePath); + } + + File.Move(stagedPath, gamePath); + } + catch (Exception ex) + { + gameDatabase.RemoveGame(gameInfo); + throw new IOException("Could not finalize the private game copy.", ex); + } + + dialogService.Show(Severity.Info, + ButtonType.OK, + translationService.Translate("Success"), + translationService.Translate("Success_Description_Install"), + null); - LoadGameList(); + LoadGameList(); + } + catch (Exception ex) + { + Util.Logging.Logger.Error(Util.Logging.LogClass.Loader, + $"Game metadata parsing failed for private import: {ex}"); + dialogService.Show(Severity.Error, + ButtonType.OK, + translationService.Translate("Error"), + translationService.Translate("Error_Description_NotMophun"), + null); + } + finally + { + try + { + if (File.Exists(stagedPath)) + { + File.Delete(stagedPath); } } catch (Exception ex) { - Util.Logging.Logger.Error(Util.Logging.LogClass.Loader, - $"Game metadata parsing failed for selected import: {ex}"); - dialogService.Show(Severity.Error, - ButtonType.OK, - translationService.Translate("Error"), - translationService.Translate("Error_Description_NotMophun"), - null); + Util.Logging.Logger.Warning(Util.Logging.LogClass.Loader, + $"Could not remove staged game import: {ex}"); } } } @@ -327,6 +357,11 @@ private void OnInstallButtonClicked() { name = "Mophun game", spec = "application/octet-stream" + }, + new FilterItem + { + name = "Mophun game (unknown type)", + spec = "*/*" } #endif }, (string path) => diff --git a/Assets/Scripts/VM/VMSystem.cs b/Assets/Scripts/VM/VMSystem.cs index 4f9af73..e0983f3 100644 --- a/Assets/Scripts/VM/VMSystem.cs +++ b/Assets/Scripts/VM/VMSystem.cs @@ -62,7 +62,7 @@ public partial class VMSystem : IDisposable private uint taskStackSectionAddress; private uint taskTerminateSubAddress; - private bool shouldStop = false; + private volatile bool shouldStop = false; private string gameName; public GameSetting GameSetting { get; set; } @@ -267,7 +267,7 @@ public void PostInitialize() public void Stop() { shouldStop = true; - processor.Stop(); + processor?.Stop(); } public bool RunDestructor() diff --git a/Assets/Tests/Editor/Onlyfun.Tests.asmdef b/Assets/Tests/Editor/Onlyfun.Tests.asmdef new file mode 100644 index 0000000..4b84093 --- /dev/null +++ b/Assets/Tests/Editor/Onlyfun.Tests.asmdef @@ -0,0 +1,19 @@ +{ + "name": "Onlyfun.Tests", + "rootNamespace": "Nofun.Tests", + "references": [], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": false, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false, + "optionalUnityReferences": [ + "TestAssemblies" + ] +} diff --git a/Assets/Tests/Editor/Onlyfun.Tests.asmdef.meta b/Assets/Tests/Editor/Onlyfun.Tests.asmdef.meta new file mode 100644 index 0000000..7806e93 --- /dev/null +++ b/Assets/Tests/Editor/Onlyfun.Tests.asmdef.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d6f4a899092c4bb7b4caf9e9111ad4f4 diff --git a/Assets/Tests/Editor/OnlyfunReliabilityTests.cs b/Assets/Tests/Editor/OnlyfunReliabilityTests.cs index 3f22b8b..955520f 100644 --- a/Assets/Tests/Editor/OnlyfunReliabilityTests.cs +++ b/Assets/Tests/Editor/OnlyfunReliabilityTests.cs @@ -4,21 +4,22 @@ * Licensed under the Apache License, Version 2.0 (the "License"); */ +using System; using System.IO; -using Nofun.Module.VMGPCaps; -using Nofun.Services; -using Nofun.Settings; +using System.Reflection; using NUnit.Framework; namespace Nofun.Tests { public class OnlyfunReliabilityTests { + private Assembly runtimeAssembly; private string temporaryDirectory; [SetUp] public void SetUp() { + runtimeAssembly = Assembly.Load("Assembly-CSharp"); temporaryDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); Directory.CreateDirectory(temporaryDirectory); } @@ -34,15 +35,46 @@ public void TearDown() [TestCase("honey cave 2")] public void HoneyCave2GetsExactLegacyProfile(string title) { - GameSetting profile = GameProfileResolver.Resolve(title, null); - - Assert.That(profile.screenSizeX, Is.EqualTo(101)); - Assert.That(profile.screenSizeY, Is.EqualTo(80)); - Assert.That(profile.orientation, Is.EqualTo(ScreenOrientation.Potrait)); - Assert.That(profile.deviceModel, Is.EqualTo(SystemDeviceModel.SonyEricssonT310)); - Assert.That(profile.systemVersion, Is.EqualTo(SystemVersion.Version130)); - Assert.That(profile.cpuBackend, Is.EqualTo(CPUBackend.Interpreter)); - Assert.That(profile.fps, Is.EqualTo(15)); + Type resolverType = RuntimeType("Nofun.Settings.GameProfileResolver"); + object profile = resolverType.GetMethod("Resolve").Invoke(null, new[] { title, null }); + AssertHoneyCaveProfile(profile); + } + + [Test] + public void ExternalHoneyCaveFixtureParsesAndResolvesExactProfile() + { + string path = Environment.GetEnvironmentVariable("ONLYFUN_TEST_GAME"); + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) + { + Assert.Ignore("Set ONLYFUN_TEST_GAME to a developer-owned Honey Cave 2 .mpn file."); + } + + Type executableType = RuntimeType("Nofun.Parser.VMGPExecutable"); + Type metadataExtensionType = RuntimeType("Nofun.Parser.VMGPExecutableExtension"); + Type resolverType = RuntimeType("Nofun.Settings.GameProfileResolver"); + + using (FileStream stream = File.OpenRead(path)) + { + object executable = Activator.CreateInstance(executableType, stream); + try + { + object metadata = metadataExtensionType.GetMethod("GetMetaInfo") + .Invoke(null, new[] { executable }); + Assert.That(metadata, Is.Not.Null); + + string title = (string)metadata.GetType().GetMethod("Get") + .Invoke(metadata, new object[] { "Title" }); + Assert.That(title, Is.EqualTo("HoneyCave2")); + + object profile = resolverType.GetMethod("Resolve") + .Invoke(null, new[] { title, executable }); + AssertHoneyCaveProfile(profile); + } + finally + { + ((IDisposable)executable).Dispose(); + } + } } [Test] @@ -53,10 +85,10 @@ public void ImportCopiesGameToPrivateDestination() byte[] contents = { 1, 2, 3, 4 }; File.WriteAllBytes(source, contents); - GameImportResult result = new GameImportService().Import(source, destination); + object result = Import(source, destination); - Assert.That(result.Succeeded, Is.True); - Assert.That(result.ImportedPath, Is.EqualTo(destination)); + Assert.That(Property(result, "Succeeded"), Is.True); + Assert.That(Property(result, "ImportedPath"), Is.EqualTo(destination)); Assert.That(File.ReadAllBytes(destination), Is.EqualTo(contents)); } @@ -66,11 +98,39 @@ public void ImportRejectsEmptyGameWithTypedError() string source = Path.Combine(temporaryDirectory, "empty.mpn"); File.WriteAllBytes(source, new byte[0]); - GameImportResult result = new GameImportService().Import(source, + object result = Import(source, Path.Combine(temporaryDirectory, "__Games", "00000001.mpn")); - Assert.That(result.Succeeded, Is.False); - Assert.That(result.ErrorCode, Is.EqualTo(GameImportErrorCode.EmptyFile)); + Assert.That(Property(result, "Succeeded"), Is.False); + Assert.That(Property(result, "ErrorCode").ToString(), Is.EqualTo("EmptyFile")); + } + + private object Import(string source, string destination) + { + Type serviceType = RuntimeType("Nofun.Services.GameImportService"); + object service = Activator.CreateInstance(serviceType); + return serviceType.GetMethod("Import").Invoke(service, new[] { source, destination }); + } + + private Type RuntimeType(string name) => + runtimeAssembly.GetType(name, true); + + private static object Field(Type type, object instance, string name) => + type.GetField(name).GetValue(instance); + + private static object Property(object instance, string name) => + instance.GetType().GetProperty(name).GetValue(instance); + + private static void AssertHoneyCaveProfile(object profile) + { + Type profileType = profile.GetType(); + Assert.That(Field(profileType, profile, "screenSizeX"), Is.EqualTo(101)); + Assert.That(Field(profileType, profile, "screenSizeY"), Is.EqualTo(80)); + Assert.That(Field(profileType, profile, "orientation").ToString(), Is.EqualTo("Potrait")); + Assert.That(Field(profileType, profile, "deviceModel").ToString(), Is.EqualTo("SonyEricssonT310")); + Assert.That(Field(profileType, profile, "systemVersion").ToString(), Is.EqualTo("Version130")); + Assert.That(Field(profileType, profile, "cpuBackend").ToString(), Is.EqualTo("Interpreter")); + Assert.That(Field(profileType, profile, "fps"), Is.EqualTo(15)); } } } From df84972fbffc0c0e9e6d3c05fa9c48f358843919 Mon Sep 17 00:00:00 2001 From: Xurshid Muhammadiyev Date: Tue, 4 Aug 2026 12:42:27 +0500 Subject: [PATCH 07/13] test: compare aliased device model by value --- Assets/Tests/Editor/OnlyfunReliabilityTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Assets/Tests/Editor/OnlyfunReliabilityTests.cs b/Assets/Tests/Editor/OnlyfunReliabilityTests.cs index 955520f..824a669 100644 --- a/Assets/Tests/Editor/OnlyfunReliabilityTests.cs +++ b/Assets/Tests/Editor/OnlyfunReliabilityTests.cs @@ -127,7 +127,8 @@ private static void AssertHoneyCaveProfile(object profile) Assert.That(Field(profileType, profile, "screenSizeX"), Is.EqualTo(101)); Assert.That(Field(profileType, profile, "screenSizeY"), Is.EqualTo(80)); Assert.That(Field(profileType, profile, "orientation").ToString(), Is.EqualTo("Potrait")); - Assert.That(Field(profileType, profile, "deviceModel").ToString(), Is.EqualTo("SonyEricssonT310")); + object deviceModel = Field(profileType, profile, "deviceModel"); + Assert.That(deviceModel, Is.EqualTo(Enum.Parse(deviceModel.GetType(), "SonyEricssonT310"))); Assert.That(Field(profileType, profile, "systemVersion").ToString(), Is.EqualTo("Version130")); Assert.That(Field(profileType, profile, "cpuBackend").ToString(), Is.EqualTo("Interpreter")); Assert.That(Field(profileType, profile, "fps"), Is.EqualTo(15)); From a20d8fa06f616cea7751868097428473aaf51876 Mon Sep 17 00:00:00 2001 From: Xurshid Muhammadiyev Date: Tue, 4 Aug 2026 12:47:21 +0500 Subject: [PATCH 08/13] fix(android): adapt to async native file picker --- Assets/Plugins/FilePicker/FilePicker.cs | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/Assets/Plugins/FilePicker/FilePicker.cs b/Assets/Plugins/FilePicker/FilePicker.cs index f3da41e..f1f9bcd 100644 --- a/Assets/Plugins/FilePicker/FilePicker.cs +++ b/Assets/Plugins/FilePicker/FilePicker.cs @@ -54,18 +54,20 @@ public static bool OpenPickFileDialog(FilterItem[] filters, Action onPat #elif UNITY_ANDROID public static bool OpenPickFileDialog(FilterItem[] filters, Action onPathReceived, string defaultPath = null) { - if (NativeFilePicker.PickFile( - (string path) => onPathReceived(path), + NativeFilePicker.PickFile( + (string path) => + { + if (string.IsNullOrEmpty(path)) + { + Debug.LogWarning("Open file picker was cancelled or permission was denied."); + } + + onPathReceived(path); + }, filters.Select(item => item.spec).ToArray() - ) != NativeFilePicker.Permission.Granted) - { - Debug.LogError("Open file picker permission denied!"); - return false; - } - else - { - return true; - } + ); + + return true; } #endif } From a5d7d3a49bd5247e09bdd12e5195fd21f9612372 Mon Sep 17 00:00:00 2001 From: Xurshid Muhammadiyev Date: Tue, 4 Aug 2026 16:28:41 +0500 Subject: [PATCH 09/13] feat: offer to save error log on game load failure When a game fails to load or crashes at runtime, the error dialog now shows Yes/No buttons instead of just OK. Pressing Yes opens a native save-file dialog so the user can export onlyfun.log to any location. - NofunRunner: changed both HandleLoadFailure and HandleSystemThreadFinished to use ButtonType.YesNo with a log- export callback - FilePicker: added static ExportLog() supporting Editor, Standalone (Win/Mac/Linux) and Android targets - NativeFileDialog: added NFD_SaveDialogU8 P/Invoke binding and OpenSaveFileDialog() helper for desktop builds --- Assets/Plugins/FilePicker/FilePicker.cs | 48 +++++++++++++ .../NativeFileDialog/NativeFileDialog.cs | 71 +++++++++++++++++++ Assets/Scripts/NofunRunner.cs | 26 +++++-- 3 files changed, 139 insertions(+), 6 deletions(-) diff --git a/Assets/Plugins/FilePicker/FilePicker.cs b/Assets/Plugins/FilePicker/FilePicker.cs index f1f9bcd..4a27f2c 100644 --- a/Assets/Plugins/FilePicker/FilePicker.cs +++ b/Assets/Plugins/FilePicker/FilePicker.cs @@ -70,5 +70,53 @@ public static bool OpenPickFileDialog(FilterItem[] filters, Action onPat return true; } #endif + + public static void ExportLog(string sourcePath, Action onFinished) + { +#if UNITY_EDITOR + string path = UnityEditor.EditorUtility.SaveFilePanel("Save Log", "", "onlyfun.log", "log"); + if (!string.IsNullOrEmpty(path)) + { + try + { + System.IO.File.Copy(sourcePath, path, true); + onFinished?.Invoke(true); + } + catch (Exception ex) + { + Debug.LogError($"Failed to export log: {ex}"); + onFinished?.Invoke(false); + } + } + else + { + onFinished?.Invoke(false); + } +#elif UNITY_STANDALONE_OSX || UNITY_STANDALONE_WIN || UNITY_STANDALONE_LINUX + FilterItem[] filters = new FilterItem[] { new FilterItem { name = "Log file", spec = "log" } }; + string path = NativeFileDialog.OpenSaveFileDialog(filters, null, "onlyfun.log"); + if (!string.IsNullOrEmpty(path)) + { + try + { + System.IO.File.Copy(sourcePath, path, true); + onFinished?.Invoke(true); + } + catch (Exception ex) + { + UnityEngine.Debug.LogError($"Failed to export log: {ex}"); + onFinished?.Invoke(false); + } + } + else + { + onFinished?.Invoke(false); + } +#elif UNITY_ANDROID + NativeFilePicker.ExportFile(sourcePath, onFinished); +#else + onFinished?.Invoke(false); +#endif + } } } diff --git a/Assets/Plugins/NativeFileDialog/NativeFileDialog.cs b/Assets/Plugins/NativeFileDialog/NativeFileDialog.cs index 11b4ea7..617f8f0 100644 --- a/Assets/Plugins/NativeFileDialog/NativeFileDialog.cs +++ b/Assets/Plugins/NativeFileDialog/NativeFileDialog.cs @@ -109,6 +109,77 @@ public static string OpenPickFileDialog(FilterItem[] filters, string defaultPath return path; } + + [DllImport("nfd", EntryPoint = "NFD_SaveDialogU8")] + private static extern int NFD_SaveDialogU8(out IntPtr outPath, IntPtr filterList, uint count, IntPtr defaultPath, IntPtr defaultName); + + public static string OpenSaveFileDialog(FilterItem[] filters, string defaultPath = null, string defaultName = null) + { + IntPtr filterList = IntPtr.Zero; + NFDU8FilterItem[] filterItems = null; + GCHandle filterListHandle = default; + + if (filters != null) + { + filterItems = new NFDU8FilterItem[filters.Length]; + for (int i = 0; i < filters.Length; i++) + { + filterItems[i].name = StringToMarshalledUtf8(filters[i].name); + filterItems[i].spec = StringToMarshalledUtf8(filters[i].spec); + } + filterListHandle = GCHandle.Alloc(filterItems, GCHandleType.Pinned); + filterList = filterListHandle.AddrOfPinnedObject(); + } + + IntPtr defaultPathPtr = IntPtr.Zero; + if (defaultPath != null) + { + defaultPathPtr = StringToMarshalledUtf8(defaultPath); + } + + IntPtr defaultNamePtr = IntPtr.Zero; + if (defaultName != null) + { + defaultNamePtr = StringToMarshalledUtf8(defaultName); + } + + IntPtr outPath = IntPtr.Zero; + int result = NFD_SaveDialogU8(out outPath, filterList, filters == null ? 0 : (uint)filters.Length, defaultPathPtr, defaultNamePtr); + + if (result != NFD_RESULT_OK) + { + return null; + } + + string path = Marshal.PtrToStringUTF8(outPath); + NFD_FreePathU8(outPath); + + if (defaultPathPtr != IntPtr.Zero) + { + FreeMarshalledUtf8(defaultPathPtr); + } + + if (defaultNamePtr != IntPtr.Zero) + { + FreeMarshalledUtf8(defaultNamePtr); + } + + if (filterList != IntPtr.Zero) + { + for (int i = 0; i < filters.Length; i++) + { + FreeMarshalledUtf8(filterItems[i].name); + FreeMarshalledUtf8(filterItems[i].spec); + } + + if (filterListHandle.IsAllocated) + { + filterListHandle.Free(); + } + } + + return path; + } } } #endif \ No newline at end of file diff --git a/Assets/Scripts/NofunRunner.cs b/Assets/Scripts/NofunRunner.cs index 1e091e1..bb8f169 100644 --- a/Assets/Scripts/NofunRunner.cs +++ b/Assets/Scripts/NofunRunner.cs @@ -346,10 +346,17 @@ private void HandleLoadFailure(Stream gameStream, System.Exception ex, string st settingDocument.ExitGameRequested -= HandleExitGame; gameListDocumentController.ImmediateShow(); - dialogService.Show(Severity.Error, ButtonType.OK, + dialogService.Show(Severity.Error, ButtonType.YesNo, null, - translationService.Translate("Error_Description_GameNotCompatible"), - null); + translationService.Translate("Error_Description_GameNotCompatible") + "\n\nDo you want to save the error logs?", + (int result) => + { + if (result == 0) + { + string logPath = System.IO.Path.Combine(Application.persistentDataPath, "onlyfun.log"); + Nofun.Plugins.FilePicker.ExportLog(logPath, null); + } + }); } public bool StartGameImpl(Stream gameStream, string targetExecutable) @@ -438,10 +445,17 @@ private void HandleSystemThreadFinished(VMSystem finishedSystem, System.Exceptio if (failure != null) { - dialogService.Show(Severity.Error, ButtonType.OK, + dialogService.Show(Severity.Error, ButtonType.YesNo, null, - translationService.Translate("Error_Description_GameNotCompatible"), - null); + translationService.Translate("Error_Description_GameNotCompatible") + "\n\nDo you want to save the error logs?", + (int result) => + { + if (result == 0) + { + string logPath = System.IO.Path.Combine(Application.persistentDataPath, "onlyfun.log"); + Nofun.Plugins.FilePicker.ExportLog(logPath, null); + } + }); } } From f8e9f85ed5a5d941d5c98c9cc4769c8b2e9267e1 Mon Sep 17 00:00:00 2001 From: Xurshid Muhammadiyev Date: Mon, 10 Aug 2026 15:14:22 +0500 Subject: [PATCH 10/13] fix(android): diagnose encrypted games and export logs --- Assets/Plugins/FilePicker/FilePicker.cs | 2 +- .../NativeFileDialog/NativeFileDialog.cs | 94 ++++++++++-------- .../Scripts/Module/VMGP/System/MessageBox.cs | 43 ++++++--- Assets/Scripts/NofunRunner.cs | 25 ++--- .../Scripts/PIP2/Interpreter/Interpreter.cs | 42 +++++--- Assets/Scripts/VM/VMSystem.cs | 4 +- .../Tests/Editor/OnlyfunReliabilityTests.cs | 95 +++++++++++++++++++ 7 files changed, 223 insertions(+), 82 deletions(-) diff --git a/Assets/Plugins/FilePicker/FilePicker.cs b/Assets/Plugins/FilePicker/FilePicker.cs index 4a27f2c..1373469 100644 --- a/Assets/Plugins/FilePicker/FilePicker.cs +++ b/Assets/Plugins/FilePicker/FilePicker.cs @@ -113,7 +113,7 @@ public static void ExportLog(string sourcePath, Action onFinished) onFinished?.Invoke(false); } #elif UNITY_ANDROID - NativeFilePicker.ExportFile(sourcePath, onFinished); + NativeFilePicker.ExportFile(sourcePath, success => onFinished?.Invoke(success)); #else onFinished?.Invoke(false); #endif diff --git a/Assets/Plugins/NativeFileDialog/NativeFileDialog.cs b/Assets/Plugins/NativeFileDialog/NativeFileDialog.cs index 617f8f0..173e6b4 100644 --- a/Assets/Plugins/NativeFileDialog/NativeFileDialog.cs +++ b/Assets/Plugins/NativeFileDialog/NativeFileDialog.cs @@ -118,58 +118,70 @@ public static string OpenSaveFileDialog(FilterItem[] filters, string defaultPath IntPtr filterList = IntPtr.Zero; NFDU8FilterItem[] filterItems = null; GCHandle filterListHandle = default; + IntPtr defaultPathPtr = IntPtr.Zero; + IntPtr defaultNamePtr = IntPtr.Zero; + IntPtr outPath = IntPtr.Zero; - if (filters != null) + try { - filterItems = new NFDU8FilterItem[filters.Length]; - for (int i = 0; i < filters.Length; i++) + if (filters != null) { - filterItems[i].name = StringToMarshalledUtf8(filters[i].name); - filterItems[i].spec = StringToMarshalledUtf8(filters[i].spec); + filterItems = new NFDU8FilterItem[filters.Length]; + for (int i = 0; i < filters.Length; i++) + { + filterItems[i].name = StringToMarshalledUtf8(filters[i].name); + filterItems[i].spec = StringToMarshalledUtf8(filters[i].spec); + } + filterListHandle = GCHandle.Alloc(filterItems, GCHandleType.Pinned); + filterList = filterListHandle.AddrOfPinnedObject(); } - filterListHandle = GCHandle.Alloc(filterItems, GCHandleType.Pinned); - filterList = filterListHandle.AddrOfPinnedObject(); - } - IntPtr defaultPathPtr = IntPtr.Zero; - if (defaultPath != null) - { - defaultPathPtr = StringToMarshalledUtf8(defaultPath); - } + if (defaultPath != null) + { + defaultPathPtr = StringToMarshalledUtf8(defaultPath); + } - IntPtr defaultNamePtr = IntPtr.Zero; - if (defaultName != null) - { - defaultNamePtr = StringToMarshalledUtf8(defaultName); - } + if (defaultName != null) + { + defaultNamePtr = StringToMarshalledUtf8(defaultName); + } - IntPtr outPath = IntPtr.Zero; - int result = NFD_SaveDialogU8(out outPath, filterList, filters == null ? 0 : (uint)filters.Length, defaultPathPtr, defaultNamePtr); + int result = NFD_SaveDialogU8(out outPath, filterList, + filters == null ? 0 : (uint)filters.Length, defaultPathPtr, defaultNamePtr); - if (result != NFD_RESULT_OK) - { - return null; + return result == NFD_RESULT_OK ? Marshal.PtrToStringUTF8(outPath) : null; } - - string path = Marshal.PtrToStringUTF8(outPath); - NFD_FreePathU8(outPath); - - if (defaultPathPtr != IntPtr.Zero) + finally { - FreeMarshalledUtf8(defaultPathPtr); - } + if (outPath != IntPtr.Zero) + { + NFD_FreePathU8(outPath); + } - if (defaultNamePtr != IntPtr.Zero) - { - FreeMarshalledUtf8(defaultNamePtr); - } + if (defaultPathPtr != IntPtr.Zero) + { + FreeMarshalledUtf8(defaultPathPtr); + } - if (filterList != IntPtr.Zero) - { - for (int i = 0; i < filters.Length; i++) + if (defaultNamePtr != IntPtr.Zero) { - FreeMarshalledUtf8(filterItems[i].name); - FreeMarshalledUtf8(filterItems[i].spec); + FreeMarshalledUtf8(defaultNamePtr); + } + + if (filterItems != null) + { + for (int i = 0; i < filterItems.Length; i++) + { + if (filterItems[i].name != IntPtr.Zero) + { + FreeMarshalledUtf8(filterItems[i].name); + } + + if (filterItems[i].spec != IntPtr.Zero) + { + FreeMarshalledUtf8(filterItems[i].spec); + } + } } if (filterListHandle.IsAllocated) @@ -177,9 +189,7 @@ public static string OpenSaveFileDialog(FilterItem[] filters, string defaultPath filterListHandle.Free(); } } - - return path; } } } -#endif \ No newline at end of file +#endif diff --git a/Assets/Scripts/Module/VMGP/System/MessageBox.cs b/Assets/Scripts/Module/VMGP/System/MessageBox.cs index c1ae27f..bf89269 100644 --- a/Assets/Scripts/Module/VMGP/System/MessageBox.cs +++ b/Assets/Scripts/Module/VMGP/System/MessageBox.cs @@ -27,24 +27,43 @@ public partial class VMGP { [ModuleCall] - private int vMsgBox(uint flags, VMString message, VMString optionalTitle) + private int vMsgBox(int flags, VMString message, VMString optionalTitle) { + return ShowMessageBox(flags, message, optionalTitle, false); + } + + [ModuleCall] + private int vMsgBoxU(int flags, VMString message, VMString optionalTitle) + { + return ShowMessageBox(flags, message, optionalTitle, true); + } + + private static int ToMophunButtonValue(int uiButtonValue) + { + // Onlyfun's dialogs report the right-hand OK/Yes button as 0, while + // Mophun specifies OK/Yes as 1 and No/Cancel as 0. + return uiButtonValue == 0 ? (int)MessageBoxFlags.OK : (int)MessageBoxFlags.Cancel; + } + + private int ShowMessageBox(int flags, VMString message, VMString optionalTitle, bool isUnicode) + { + uint flagBits = unchecked((uint)flags); Severity boxSeverity; switch (true) { - case true when BitUtil.FlagSet(flags, MessageBoxFlags.Error): + case true when BitUtil.FlagSet(flagBits, MessageBoxFlags.Error): boxSeverity = Severity.Error; break; - case true when BitUtil.FlagSet(flags, MessageBoxFlags.Warning): + case true when BitUtil.FlagSet(flagBits, MessageBoxFlags.Warning): boxSeverity = Severity.Warning; break; - case true when BitUtil.FlagSet(flags, MessageBoxFlags.Info): + case true when BitUtil.FlagSet(flagBits, MessageBoxFlags.Info): boxSeverity = Severity.Info; break; - case true when BitUtil.FlagSet(flags, MessageBoxFlags.Question): + case true when BitUtil.FlagSet(flagBits, MessageBoxFlags.Question): boxSeverity = Severity.Question; break; @@ -57,11 +76,11 @@ private int vMsgBox(uint flags, VMString message, VMString optionalTitle) ButtonType buttonType; switch (true) { - case true when BitUtil.FlagSet(flags, MessageBoxFlags.OKCancel): + case true when BitUtil.FlagSet(flagBits, MessageBoxFlags.OKCancel): buttonType = ButtonType.OKCancel; break; - case true when BitUtil.FlagSet(flags, MessageBoxFlags.YesNo): + case true when BitUtil.FlagSet(flagBits, MessageBoxFlags.YesNo): buttonType = ButtonType.YesNo; break; @@ -73,21 +92,21 @@ private int vMsgBox(uint flags, VMString message, VMString optionalTitle) string title = null; - if (BitUtil.FlagSet(flags, MessageBoxFlags.Title)) + if (BitUtil.FlagSet(flagBits, MessageBoxFlags.Title)) { - title = optionalTitle.Get(system.Memory); + title = optionalTitle.Get(system.Memory, isUnicode); } - string content = message.Get(system.Memory); + string content = message.Get(system.Memory, isUnicode); int buttonValue = 0; // 0 is already cancel system.UIDriver.Show(boxSeverity, title, content, buttonType, (int button) => { - buttonValue = button; + buttonValue = ToMophunButtonValue(button); }); return buttonValue; } } -} \ No newline at end of file +} diff --git a/Assets/Scripts/NofunRunner.cs b/Assets/Scripts/NofunRunner.cs index bb8f169..7509747 100644 --- a/Assets/Scripts/NofunRunner.cs +++ b/Assets/Scripts/NofunRunner.cs @@ -346,9 +346,20 @@ private void HandleLoadFailure(Stream gameStream, System.Exception ex, string st settingDocument.ExitGameRequested -= HandleExitGame; gameListDocumentController.ImmediateShow(); + ShowGameFailureDialog(ex); + } + + private void ShowGameFailureDialog(System.Exception failure) + { + string description = translationService.Translate("Error_Description_GameNotCompatible"); + if (failure != null && !string.IsNullOrWhiteSpace(failure.Message)) + { + description += $"\n\nDetails: {failure.Message}"; + } + dialogService.Show(Severity.Error, ButtonType.YesNo, null, - translationService.Translate("Error_Description_GameNotCompatible") + "\n\nDo you want to save the error logs?", + description + "\n\nDo you want to save the error logs?", (int result) => { if (result == 0) @@ -445,17 +456,7 @@ private void HandleSystemThreadFinished(VMSystem finishedSystem, System.Exceptio if (failure != null) { - dialogService.Show(Severity.Error, ButtonType.YesNo, - null, - translationService.Translate("Error_Description_GameNotCompatible") + "\n\nDo you want to save the error logs?", - (int result) => - { - if (result == 0) - { - string logPath = System.IO.Path.Combine(Application.persistentDataPath, "onlyfun.log"); - Nofun.Plugins.FilePicker.ExportLog(logPath, null); - } - }); + ShowGameFailureDialog(failure); } } diff --git a/Assets/Scripts/PIP2/Interpreter/Interpreter.cs b/Assets/Scripts/PIP2/Interpreter/Interpreter.cs index 0440786..2a0bec9 100644 --- a/Assets/Scripts/PIP2/Interpreter/Interpreter.cs +++ b/Assets/Scripts/PIP2/Interpreter/Interpreter.cs @@ -143,25 +143,41 @@ public override void Run(int instructionPerRun) shouldStop = false; isRunning = true; - instructionRan = 0; - - while (!shouldStop && (instructionRan < instructionPerRun)) + try { - uint value = config.ReadCode(registers[Register.PCIndex]); - Action handler = OpcodeTables[value & 0xFF]; + instructionRan = 0; - if (handler == null) + while (!shouldStop && (instructionRan < instructionPerRun)) { - throw new InvalidOperationException($"Unimplemented opcode {(Opcode)(value & 0xFF)} at PC={registers[Register.PCIndex]}"); - } + uint programCounter = registers[Register.PCIndex]; + uint value = config.ReadCode(programCounter); + uint opcode = value & 0xFF; - registers[Register.PCIndex] += InstructionSize; - handler(value); + if (opcode >= OpcodeTables.Length) + { + throw new InvalidProgramException( + $"Invalid opcode 0x{opcode:X2} at PC=0x{programCounter:X8}. " + + "The Mophun code section is probably still encrypted or is corrupt."); + } - instructionRan++; - } + Action handler = OpcodeTables[opcode]; + + if (handler == null) + { + throw new InvalidProgramException( + $"Unsupported opcode 0x{opcode:X2} at PC=0x{programCounter:X8}."); + } + + registers[Register.PCIndex] += InstructionSize; + handler(value); - isRunning = false; + instructionRan++; + } + } + finally + { + isRunning = false; + } } public override void Stop() diff --git a/Assets/Scripts/VM/VMSystem.cs b/Assets/Scripts/VM/VMSystem.cs index e0983f3..fed8d62 100644 --- a/Assets/Scripts/VM/VMSystem.cs +++ b/Assets/Scripts/VM/VMSystem.cs @@ -294,10 +294,10 @@ public void Run() { processor.Run(InstructionPerRun); } - catch (Exception ex) + catch { shouldStop = true; - throw ex; + throw; } inputDriver.EndFrame(); diff --git a/Assets/Tests/Editor/OnlyfunReliabilityTests.cs b/Assets/Tests/Editor/OnlyfunReliabilityTests.cs index 824a669..e642dc9 100644 --- a/Assets/Tests/Editor/OnlyfunReliabilityTests.cs +++ b/Assets/Tests/Editor/OnlyfunReliabilityTests.cs @@ -77,6 +77,101 @@ public void ExternalHoneyCaveFixtureParsesAndResolvesExactProfile() } } + [Test] + public void InterpreterReportsOutOfRangeOpcodeAsProbablyEncrypted() + { + Type configType = RuntimeType("Nofun.PIP2.ProcessorConfig"); + object config = Activator.CreateInstance(configType); + configType.GetField("ReadCode").SetValue(config, + new Func(_ => 0xD5021BF3)); + + Type interpreterType = RuntimeType("Nofun.PIP2.Interpreter.Interpreter"); + object interpreter = Activator.CreateInstance(interpreterType, config); + uint[] registers = (uint[])interpreterType.BaseType.GetField("registers", + BindingFlags.Instance | BindingFlags.NonPublic).GetValue(interpreter); + registers[32] = 0x1000; + + TargetInvocationException invocation = Assert.Throws(() => + interpreterType.GetMethod("Run").Invoke(interpreter, new object[] { 1 })); + + Assert.That(invocation.InnerException, Is.TypeOf()); + Assert.That(invocation.InnerException.Message, Does.Contain("0xF3")); + Assert.That(invocation.InnerException.Message, Does.Contain("0x00001000")); + Assert.That(invocation.InnerException.Message, Does.Contain("encrypted")); + + TargetInvocationException secondInvocation = Assert.Throws(() => + interpreterType.GetMethod("Run").Invoke(interpreter, new object[] { 1 })); + Assert.That(secondInvocation.InnerException, Is.TypeOf(), + "The interpreter must leave its running state after a failed instruction."); + } + + [Test] + public void FileLoggerPreservesVmFailureDetails() + { + Type targetType = RuntimeType("Nofun.Util.Unity.FileLogTarget"); + object target = Activator.CreateInstance(targetType, temporaryDirectory); + Type loggerType = RuntimeType("Nofun.Util.Logging.Logger"); + Type logClassType = RuntimeType("Nofun.Util.Logging.LogClass"); + object loaderClass = Enum.Parse(logClassType, "Loader"); + string failure = new InvalidProgramException( + "Invalid opcode 0xF3 at PC=0x00001000. The Mophun code section is probably still encrypted.").ToString(); + + loggerType.GetMethod("AddTarget").Invoke(null, new[] { target }); + try + { + loggerType.GetMethod("Error").Invoke(null, + new[] { loaderClass, $"VM initialization or execution failed: {failure}" }); + } + finally + { + loggerType.GetMethod("RemoveTarget").Invoke(null, new[] { target }); + } + + string logPath = (string)targetType.GetProperty("LogPath").GetValue(target); + string contents = File.ReadAllText(logPath); + Assert.That(contents, Does.Contain("[Error] [Loader]")); + Assert.That(contents, Does.Contain("InvalidProgramException")); + Assert.That(contents, Does.Contain("0xF3")); + Assert.That(contents, Does.Contain("0x00001000")); + } + + [Test] + public void UnicodeMessageBoxMatchesSdkSignatureAndIsRegistered() + { + Type moduleType = RuntimeType("Nofun.Module.VMGP.VMGP"); + Type vmStringType = RuntimeType("Nofun.VM.VMString"); + MethodInfo method = moduleType.GetMethod("vMsgBoxU", + BindingFlags.Instance | BindingFlags.NonPublic); + + Assert.That(method, Is.Not.Null); + Assert.That(method.ReturnType, Is.EqualTo(typeof(int))); + ParameterInfo[] parameters = method.GetParameters(); + Assert.That(parameters.Length, Is.EqualTo(3)); + Assert.That(parameters[0].ParameterType, Is.EqualTo(typeof(int))); + Assert.That(parameters[1].ParameterType, Is.EqualTo(vmStringType)); + Assert.That(parameters[2].ParameterType, Is.EqualTo(vmStringType)); + + Type callMapType = RuntimeType("Nofun.VM.VMCallMap"); + object callMap = Activator.CreateInstance(callMapType, new object[] { null }); + object module = System.Runtime.Serialization.FormatterServices + .GetUninitializedObject(moduleType); + RuntimeType("Nofun.Module.IModule").GetMethod("Register") + .Invoke(module, new[] { callMap }); + + object registrations = callMapType.GetField("callmap", + BindingFlags.Instance | BindingFlags.NonPublic).GetValue(callMap); + bool isRegistered = (bool)registrations.GetType().GetMethod("ContainsKey") + .Invoke(registrations, new object[] { "vMsgBoxU" }); + Assert.That(isRegistered, Is.True); + + MethodInfo buttonValueConverter = moduleType.GetMethod("ToMophunButtonValue", + BindingFlags.Static | BindingFlags.NonPublic); + Assert.That(buttonValueConverter.Invoke(null, new object[] { 0 }), Is.EqualTo(1), + "The UI's right-hand OK/Yes result must map to the Mophun success value."); + Assert.That(buttonValueConverter.Invoke(null, new object[] { 1 }), Is.EqualTo(0), + "The UI's left-hand No/Cancel result must map to the Mophun cancel value."); + } + [Test] public void ImportCopiesGameToPrivateDestination() { From b648c452c803d227622be78d5f3524445d421605 Mon Sep 17 00:00:00 2001 From: Xurshid Muhammadiyev Date: Wed, 12 Aug 2026 01:23:35 +0500 Subject: [PATCH 11/13] feat(android): decrypt encrypted Mophun games locally --- Assets/Scripts/Data/GameDatabase.cs | 17 + Assets/Scripts/Services/GameImportResult.cs | 15 +- Assets/Scripts/Services/GameImportService.cs | 128 +++++- Assets/Scripts/Services/MophunDecryptor.cs | 431 ++++++++++++++++++ .../Scripts/Services/MophunDecryptor.cs.meta | 11 + .../Scripts/UI/GameListDocumentController.cs | 34 +- .../Tests/Editor/OnlyfunReliabilityTests.cs | 70 ++- 7 files changed, 694 insertions(+), 12 deletions(-) create mode 100644 Assets/Scripts/Services/MophunDecryptor.cs create mode 100644 Assets/Scripts/Services/MophunDecryptor.cs.meta diff --git a/Assets/Scripts/Data/GameDatabase.cs b/Assets/Scripts/Data/GameDatabase.cs index 0e4bf70..e8ef5f9 100644 --- a/Assets/Scripts/Data/GameDatabase.cs +++ b/Assets/Scripts/Data/GameDatabase.cs @@ -48,6 +48,23 @@ public bool AddGame(Model.GameInfo game) } } + public Model.GameInfo FindByName(string name) + { + return _connection.Table().FirstOrDefault(x => x.Name == name); + } + + public bool UpdateGame(Model.GameInfo game) + { + try + { + return _connection.Update(game) > 0; + } + catch + { + return false; + } + } + public void RemoveGame(Model.GameInfo game) { _connection.Delete(game); diff --git a/Assets/Scripts/Services/GameImportResult.cs b/Assets/Scripts/Services/GameImportResult.cs index b019794..3d2d3ca 100644 --- a/Assets/Scripts/Services/GameImportResult.cs +++ b/Assets/Scripts/Services/GameImportResult.cs @@ -14,6 +14,9 @@ public enum GameImportErrorCode SourceUnavailable, PermissionDenied, EmptyFile, + InvalidMpn, + UnsupportedEncryption, + DecryptionFailed, CopyFailed } @@ -24,21 +27,25 @@ public readonly struct GameImportResult public GameImportErrorCode ErrorCode { get; } public string Message { get; } public Exception Exception { get; } + public bool WasDecrypted { get; } + public string SourceSha256 { get; } private GameImportResult(bool succeeded, string importedPath, GameImportErrorCode errorCode, - string message, Exception exception) + string message, Exception exception, bool wasDecrypted, string sourceSha256) { Succeeded = succeeded; ImportedPath = importedPath; ErrorCode = errorCode; Message = message; Exception = exception; + WasDecrypted = wasDecrypted; + SourceSha256 = sourceSha256; } - public static GameImportResult Success(string importedPath) => - new(true, importedPath, GameImportErrorCode.None, null, null); + public static GameImportResult Success(string importedPath, bool wasDecrypted = false, string sourceSha256 = null) => + new(true, importedPath, GameImportErrorCode.None, null, null, wasDecrypted, sourceSha256); public static GameImportResult Failure(GameImportErrorCode errorCode, string message, Exception exception = null) => - new(false, null, errorCode, message, exception); + new(false, null, errorCode, message, exception, false, null); } } diff --git a/Assets/Scripts/Services/GameImportService.cs b/Assets/Scripts/Services/GameImportService.cs index c001d4c..68e1d45 100644 --- a/Assets/Scripts/Services/GameImportService.cs +++ b/Assets/Scripts/Services/GameImportService.cs @@ -6,6 +6,7 @@ using System; using System.IO; +using System.Security.Cryptography; namespace Nofun.Services { @@ -16,6 +17,17 @@ public interface IGameImportService public sealed class GameImportService : IGameImportService { + private readonly string decryptionCacheDirectory; + + public GameImportService() : this(null) + { + } + + public GameImportService(string decryptionCacheDirectory) + { + this.decryptionCacheDirectory = decryptionCacheDirectory; + } + public GameImportResult Import(string sourcePath, string destinationPath) { if (string.IsNullOrWhiteSpace(sourcePath) || !File.Exists(sourcePath)) @@ -33,15 +45,64 @@ public GameImportResult Import(string sourcePath, string destinationPath) "The selected game file is empty."); } - Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)); + byte[] sourceBytes = File.ReadAllBytes(sourcePath); + string sourceSha256 = ComputeSha256(sourceBytes); + byte[] normalizedBytes; + bool wasDecrypted; + + string cachePath = GetCachePath(sourceSha256); + if (cachePath != null && File.Exists(cachePath)) + { + byte[] cachedBytes = File.ReadAllBytes(cachePath); + string cacheError; + if (MophunDecryptor.TryValidatePlain(cachedBytes, out cacheError)) + { + normalizedBytes = cachedBytes; + wasDecrypted = true; + } + else + { + TryDelete(cachePath); + normalizedBytes = null; + wasDecrypted = false; + } + } + else + { + MophunNormalizationResult normalization = MophunDecryptor.Normalize(sourceBytes); + if (!normalization.Succeeded) + { + GameImportErrorCode errorCode = normalization.Status == MophunNormalizationStatus.InvalidMpn + ? GameImportErrorCode.InvalidMpn + : normalization.Status == MophunNormalizationStatus.UnsupportedEncryption + ? GameImportErrorCode.UnsupportedEncryption + : GameImportErrorCode.DecryptionFailed; + return GameImportResult.Failure(errorCode, normalization.Message); + } + + normalizedBytes = normalization.Bytes; + wasDecrypted = normalization.WasDecrypted; + if (wasDecrypted && cachePath != null) + { + WriteAtomically(cachePath, normalizedBytes); + } + } + + string destinationDirectory = Path.GetDirectoryName(destinationPath); + if (string.IsNullOrEmpty(destinationDirectory)) + { + return GameImportResult.Failure(GameImportErrorCode.CopyFailed, + "Onlyfun could not determine where to store the game."); + } + + Directory.CreateDirectory(destinationDirectory); string temporaryPath = destinationPath + ".importing"; try { - using (var source = new FileStream(sourcePath, FileMode.Open, FileAccess.Read, FileShare.Read)) using (var destination = new FileStream(temporaryPath, FileMode.Create, FileAccess.Write, FileShare.None)) { - source.CopyTo(destination); + destination.Write(normalizedBytes, 0, normalizedBytes.Length); destination.Flush(); } @@ -51,7 +112,7 @@ public GameImportResult Import(string sourcePath, string destinationPath) } File.Move(temporaryPath, destinationPath); - return GameImportResult.Success(destinationPath); + return GameImportResult.Success(destinationPath, wasDecrypted, sourceSha256); } finally { @@ -72,5 +133,64 @@ public GameImportResult Import(string sourcePath, string destinationPath) "Onlyfun could not copy the selected game into its library.", ex); } } + + private string GetCachePath(string sourceSha256) + { + if (string.IsNullOrWhiteSpace(decryptionCacheDirectory)) + { + return null; + } + + Directory.CreateDirectory(decryptionCacheDirectory); + return Path.Combine(decryptionCacheDirectory, sourceSha256 + ".mpn"); + } + + private static string ComputeSha256(byte[] bytes) + { + using (SHA256 sha = SHA256.Create()) + { + byte[] hash = sha.ComputeHash(bytes); + return BitConverter.ToString(hash).Replace("-", string.Empty).ToLowerInvariant(); + } + } + + private static void WriteAtomically(string path, byte[] bytes) + { + string temporaryPath = path + ".importing"; + try + { + using (var stream = new FileStream(temporaryPath, FileMode.Create, FileAccess.Write, FileShare.None)) + { + stream.Write(bytes, 0, bytes.Length); + stream.Flush(); + } + + if (File.Exists(path)) + { + File.Delete(path); + } + + File.Move(temporaryPath, path); + } + finally + { + TryDelete(temporaryPath); + } + } + + private static void TryDelete(string path) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch + { + // A stale cache can be rebuilt on the next import. + } + } } } diff --git a/Assets/Scripts/Services/MophunDecryptor.cs b/Assets/Scripts/Services/MophunDecryptor.cs new file mode 100644 index 0000000..c3b0a74 --- /dev/null +++ b/Assets/Scripts/Services/MophunDecryptor.cs @@ -0,0 +1,431 @@ +/* + * (C) 2023 Radrat Softworks + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + */ + +using System; +using System.IO; +using System.Numerics; + +namespace Nofun.Services +{ + public enum MophunNormalizationStatus + { + Plain, + Decrypted, + InvalidMpn, + UnsupportedEncryption, + DecryptionFailed + } + + public sealed class MophunNormalizationResult + { + public MophunNormalizationStatus Status { get; private set; } + public byte[] Bytes { get; private set; } + public string Message { get; private set; } + + public bool Succeeded => Status == MophunNormalizationStatus.Plain || + Status == MophunNormalizationStatus.Decrypted; + public bool WasDecrypted => Status == MophunNormalizationStatus.Decrypted; + + private MophunNormalizationResult(MophunNormalizationStatus status, byte[] bytes, string message) + { + Status = status; + Bytes = bytes; + Message = message; + } + + public static MophunNormalizationResult Plain(byte[] bytes) => + new MophunNormalizationResult(MophunNormalizationStatus.Plain, bytes, null); + + public static MophunNormalizationResult Decrypted(byte[] bytes) => + new MophunNormalizationResult(MophunNormalizationStatus.Decrypted, bytes, null); + + public static MophunNormalizationResult Failure(MophunNormalizationStatus status, string message) => + new MophunNormalizationResult(status, null, message); + } + + /// + /// Detects and normalizes Mophun executable code. Profiles are deliberately + /// kept behind this class so another Mophun encryption family can be added + /// without changing the import pipeline. + /// + public static class MophunDecryptor + { + private const int HeaderSize = 40; + private const int MaxOpcode = 116; + private const byte CompressedFlag = 0x80; + + // The SE profile used by the Honey Cave 2 MPNs. These are embedded so + // Android users never need to select or install a key file. They are + // not used for files which are already plain or for compressed MPNs. + private const string HoneyCaveSelectorKeyBase64 = "4wuMnHTAJrTPuoIN0HKzKA=="; + private const string HoneyCaveBigKeysBase64 = + "WWM9pYVVjCI9sQ9Z73OsHV6ZAAy3MdDXcBM0JbErxhcC46WtxBjBugVg7IsZ8TEB1PwAl4SQ/5k//zrZqnF8NeKOyywUPofuJMTb9OYj6LWfMkDsaAO5jepcuSaqBECqyCHu5wVHblUbvlYI8HRQ5xWk9YQB6V4KKD8+2nL3mvD7lfx8xFQZ/nNuCqkmANJDBb5Y+q/Gvt3mAZ3A8kPev3/FevAFRmbwohYHHgwFat42XUMuzM0ruhmrsRr3kjkIPXKtze8Gc5aONuAMcmJYKixbLmblHwwPuYv8d03R45ByRTovun8avJPDAI01h+WzjXsZd9LMNcXQRdFTpYII3lW23+fJ/r9BzPn3w2V/fl21D6frAw7c7vFymAQOG+vFTeQRevRoUJkQ/fOaqPRMIaDKmCNYC74B5zdLiDnYa3t9SXYq/9HM4pVQ54ebUaP2SxWgDQUrQ0LmFwE6CB9ZMEDn7wVqJ5kDp2g6d6yJ5qh3RGZyB4JLyJc7/gFtHF+gA/fqcRNRaCM67GY0jwzE/Oox2ofkDf8XEySp4yf8gYAfNW/bLJP/Zq2xtILzPqGrpIKdghvinM3AvLFh6yjBWHXygbiBz7alLUiuvtXVizZaYOkowtaf06ZpYLIpbdUR9v2q2vATTbVolfshgpHJi2PtE2sd9IkhJptWh63gJO8="; + + private static readonly byte[] HoneyCaveSelectorKey = Convert.FromBase64String(HoneyCaveSelectorKeyBase64); + private static readonly byte[] HoneyCaveBigKeys = Convert.FromBase64String(HoneyCaveBigKeysBase64); + + private sealed class EncryptionProfile + { + public readonly string Name; + public readonly byte[] SelectorKey; + public readonly byte[] BigKeys; + + public EncryptionProfile(string name, byte[] selectorKey, byte[] bigKeys) + { + Name = name; + SelectorKey = selectorKey; + BigKeys = bigKeys; + } + } + + private static readonly EncryptionProfile HoneyCaveProfile = + new EncryptionProfile("Honey Cave 2 SE", HoneyCaveSelectorKey, HoneyCaveBigKeys); + + private struct Layout + { + public int CodeOffset; + public int CodeSize; + public int DataOffset; + public int ResourceOffset; + public int ResourceSize; + public bool IsCompressed; + } + + public static MophunNormalizationResult Normalize(byte[] source) + { + Layout layout; + string error; + if (!TryReadLayout(source, out layout, out error)) + { + return MophunNormalizationResult.Failure(MophunNormalizationStatus.InvalidMpn, error); + } + + if (layout.IsCompressed) + { + return MophunNormalizationResult.Failure( + MophunNormalizationStatus.UnsupportedEncryption, + "This MPN uses compressed sections, which are not supported yet."); + } + + if (IsPlainCode(source, layout)) + { + return MophunNormalizationResult.Plain(source); + } + + byte[] decrypted; + if (!TryDecryptWithProfile(source, layout, HoneyCaveProfile, out decrypted, out error)) + { + return MophunNormalizationResult.Failure( + error.StartsWith("Encrypted code", StringComparison.Ordinal) + ? MophunNormalizationStatus.UnsupportedEncryption + : MophunNormalizationStatus.DecryptionFailed, + error); + } + + return MophunNormalizationResult.Decrypted(decrypted); + } + + public static bool TryValidatePlain(byte[] bytes, out string error) + { + Layout layout; + if (!TryReadLayout(bytes, out layout, out error)) + { + return false; + } + + if (layout.IsCompressed) + { + error = "The cached MPN is compressed."; + return false; + } + + if (!IsPlainCode(bytes, layout)) + { + error = "The cached MPN still has encrypted or invalid executable code."; + return false; + } + + return true; + } + + private static bool TryReadLayout(byte[] bytes, out Layout layout, out string error) + { + layout = new Layout(); + error = null; + if (bytes == null || bytes.Length < HeaderSize) + { + error = "The selected file is too small to be a Mophun MPN."; + return false; + } + + if (bytes[0] != (byte)'V' || bytes[1] != (byte)'M' || bytes[2] != (byte)'G' || bytes[3] != (byte)'P') + { + error = "The selected file is not a valid VMGP/Mophun MPN."; + return false; + } + + uint codeSize = ReadUInt32(bytes, 12); + uint dataSize = ReadUInt32(bytes, 16); + uint resourceSize = ReadUInt32(bytes, 24); + uint poolSize = ReadUInt32(bytes, 32); + uint stringSize = ReadUInt32(bytes, 36); + long codeEnd = (long)HeaderSize + codeSize; + long dataEnd = codeEnd + dataSize; + long resourceEnd = dataEnd + resourceSize; + long poolEnd = resourceEnd + (long)poolSize * 8L; + long fileEnd = poolEnd + stringSize; + + if (codeSize == 0 || codeSize > int.MaxValue || dataSize > int.MaxValue || resourceSize > int.MaxValue || + poolSize > int.MaxValue || stringSize > int.MaxValue || fileEnd > bytes.Length) + { + error = "The MPN section sizes are outside the file bounds."; + return false; + } + + if ((resourceSize < 8) || dataEnd > int.MaxValue || resourceEnd > int.MaxValue) + { + error = "The MPN resource section is invalid."; + return false; + } + + layout.CodeOffset = HeaderSize; + layout.CodeSize = (int)codeSize; + layout.DataOffset = (int)codeEnd; + layout.ResourceOffset = (int)dataEnd; + layout.ResourceSize = (int)resourceSize; + layout.IsCompressed = (bytes[11] & CompressedFlag) != 0; + return true; + } + + private static bool IsPlainCode(byte[] bytes, Layout layout) + { + if (layout.CodeSize < 4) + { + return false; + } + + return (ReadUInt32(bytes, layout.CodeOffset) & 0xFF) < MaxOpcode; + } + + private static bool TryDecryptWithProfile(byte[] source, Layout layout, EncryptionProfile profile, + out byte[] result, out string error) + { + result = null; + error = null; + + byte[] meta; + if (!TryFindMeta(source, layout, out meta)) + { + error = "Encrypted code was detected, but the MPN has no readable META resource."; + return false; + } + + uint encryptedSelector = ReadUInt32(meta, 0x8C); + uint selector = XteaDecryptSelector(encryptedSelector, profile.SelectorKey); + if (selector > 3) + { + error = "Encrypted code was detected, but its Honey Cave encryption profile is unknown."; + return false; + } + + byte[] modulus = new byte[128]; + Buffer.BlockCopy(profile.BigKeys, (int)selector * 128, modulus, 0, modulus.Length); + byte[] decryptedMeta; + if (!TryRsaDeriveKey(meta, modulus, out decryptedMeta)) + { + error = "Encrypted code was detected, but the embedded Mophun profile could not derive its key."; + return false; + } + + uint[] symmetricKey = new uint[4]; + for (int i = 0; i < symmetricKey.Length; i++) + { + symmetricKey[i] = ReadUInt32(decryptedMeta, 0x14 + i * 4); + } + + result = new byte[source.Length]; + Buffer.BlockCopy(source, 0, result, 0, source.Length); + int wordCount = layout.CodeSize / 4; + for (int i = 0; i < wordCount; i++) + { + int offset = layout.CodeOffset + i * 4; + uint encryptedWord = ReadUInt32(source, offset); + WriteUInt32(result, offset, DecryptCodeWord(encryptedWord, symmetricKey)); + } + + if (!IsPlainCode(result, layout)) + { + result = null; + error = "Encrypted code was detected, but decryption did not produce valid VMGP code."; + return false; + } + + return true; + } + + private static bool TryFindMeta(byte[] source, Layout layout, out byte[] meta) + { + meta = null; + int start = layout.ResourceOffset; + int end = start + layout.ResourceSize; + if (start + 4 > end) + { + return false; + } + + uint resourceHeaderSize = ReadUInt32(source, start); + if (resourceHeaderSize < 8 || resourceHeaderSize > layout.ResourceSize || (resourceHeaderSize & 3) != 0) + { + return false; + } + + int count = (int)(resourceHeaderSize / 4) - 1; + if (count <= 0 || start + resourceHeaderSize > end) + { + return false; + } + + int previous = (int)resourceHeaderSize; + for (int i = 0; i < count; i++) + { + int current = i == count - 1 + ? layout.ResourceSize + : (int)ReadUInt32(source, start + 4 + i * 4); + if (current < previous || current > layout.ResourceSize) + { + return false; + } + + int resourceStart = start + previous; + int resourceLength = current - previous; + if (resourceLength >= 9 + 0x98 && source[resourceStart] == (byte)'M' && + source[resourceStart + 1] == (byte)'E' && source[resourceStart + 2] == (byte)'T' && + source[resourceStart + 3] == (byte)'A') + { + meta = new byte[0x98]; + Buffer.BlockCopy(source, resourceStart + 9, meta, 0, meta.Length); + return true; + } + + previous = current; + } + + return false; + } + + private static bool TryRsaDeriveKey(byte[] meta, byte[] modulusBytes, out byte[] decrypted) + { + decrypted = null; + try + { + byte[] modulusPositive = new byte[modulusBytes.Length + 1]; + byte[] metaPositive = new byte[128 + 1]; + Buffer.BlockCopy(modulusBytes, 0, modulusPositive, 0, modulusBytes.Length); + Buffer.BlockCopy(meta, 0, metaPositive, 0, 128); + BigInteger modulus = new BigInteger(modulusPositive); + BigInteger ciphertext = new BigInteger(metaPositive); + if (modulus <= 1 || ciphertext.Sign < 0) + { + return false; + } + + byte[] value = BigInteger.ModPow(ciphertext, new BigInteger(3), modulus).ToByteArray(); + decrypted = new byte[128]; + Buffer.BlockCopy(value, 0, decrypted, 0, Math.Min(value.Length, decrypted.Length)); + for (int i = 0x26; i < decrypted.Length; i++) + { + if (decrypted[i] != 0 && decrypted[i] != 0xFF && decrypted[i] != 1) + { + decrypted = null; + return false; + } + } + + return true; + } + catch + { + decrypted = null; + return false; + } + } + + private static uint XteaDecryptSelector(uint input, byte[] keyBytes) + { + uint[] key = new uint[8]; + for (int i = 0; i < key.Length; i++) + { + key[i] = (uint)(keyBytes[i * 2] | (keyBytes[i * 2 + 1] << 8)); + } + + uint sum = 0xC6EF3720; + uint v0 = input & 0xFFFF; + uint v1 = (input >> 16) & 0xFFFFFF; + for (int i = 0; i < 32; i++) + { + v1 -= (Mix(v0) ^ ((key[2 * ((sum >> 11) & 3)] + sum) & 0xFFFF)); + sum -= 0x9E3779B9; + v0 -= (Mix(v1) ^ ((key[2 * (sum & 3)] + sum) & 0xFFFF)); + } + + return ((v1 & 0xFFFF) << 16) | (v0 & 0xFFFF); + } + + private static uint DecryptCodeWord(uint block, uint[] key) + { + uint r4 = block; + uint r10 = key[0]; + uint r8 = key[1]; + uint r5 = block & 0xFFFF; + uint r7 = key[2]; + uint r9 = key[3]; + + r4 = (r4 >> 16) - KeyMix(r5, r7, 0x540F); + r5 = r5 - KeyMix(r4, r7, 0xDA56); + r4 = r4 - KeyMix(r5, r9, 0xDA56); + uint r6 = r5 - KeyMix(r4, r8, 0x609D); + r5 = r4 - KeyMix(r6, r10, 0x609D); + + r6 = r6 - (Mix(r5) ^ ((r10 + 0xE6E4) & 0xFFFF)); + r4 = r5 - (Mix(r6) ^ ((r10 + 0xE6E4) & 0xFFFF)); + r6 = r6 - KeyMix(r4, r9, 0x6D2B); + r5 = r4 - KeyMix(r6, r8, 0x6D2B); + r6 = r6 - KeyMix(r5, r7, 0xF372); + r4 = r5 - KeyMix(r6, r7, 0xF372); + r5 = r6 - KeyMix(r4, r8, 0x79B9); + uint r3 = r4 - KeyMix(r5, r9, 0x79B9); + + uint high = r3 & 0xFFFF; + uint low = r5 - (Mix(r3) ^ r10); + return ((high & 0xFFFF) << 16) | (low & 0xFFFF); + } + + private static uint KeyMix(uint value, uint key, uint constant) + { + return Mix(value) ^ ((key + constant) & 0xFFFF); + } + + private static uint Mix(uint value) + { + return (((((value & 0xFFFF) << 4) & 0xFFFF) ^ ((value & 0xFFFF) >> 5)) + value); + } + + private static uint ReadUInt32(byte[] bytes, int offset) + { + return (uint)(bytes[offset] | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16) | + (bytes[offset + 3] << 24)); + } + + private static void WriteUInt32(byte[] bytes, int offset, uint value) + { + bytes[offset] = (byte)value; + bytes[offset + 1] = (byte)(value >> 8); + bytes[offset + 2] = (byte)(value >> 16); + bytes[offset + 3] = (byte)(value >> 24); + } + } +} diff --git a/Assets/Scripts/Services/MophunDecryptor.cs.meta b/Assets/Scripts/Services/MophunDecryptor.cs.meta new file mode 100644 index 0000000..a483f65 --- /dev/null +++ b/Assets/Scripts/Services/MophunDecryptor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7e5cb42bbd1f4bc4be53e1a8cfb92d9e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/UI/GameListDocumentController.cs b/Assets/Scripts/UI/GameListDocumentController.cs index 38e3e56..32c8452 100644 --- a/Assets/Scripts/UI/GameListDocumentController.cs +++ b/Assets/Scripts/UI/GameListDocumentController.cs @@ -80,7 +80,7 @@ public override void Awake() } gameDatabase = new GameDatabase(GameDatabasePath); - gameImportService = new GameImportService(); + gameImportService = new GameImportService(Path.Combine(Application.persistentDataPath, "__MophunCache")); dynamicIconsProvider = new DynamicIconsProvider(dynamicIconRendererContainer); Directory.CreateDirectory(GamePathRoot); @@ -224,6 +224,12 @@ private void InstallGame(string path) return; } + if (importResult.WasDecrypted) + { + Util.Logging.Logger.Debug(Util.Logging.LogClass.Loader, + $"Encrypted Mophun code was decrypted locally (source SHA-256 {importResult.SourceSha256})."); + } + try { GameInfo gameInfo; @@ -280,7 +286,22 @@ private void InstallGame(string path) versionNumbers != null && versionNumbers.Length >= 3 ? versionNumbers[2] : 0); } - if (!gameDatabase.AddGame(gameInfo)) + GameInfo previousGameInfo = gameDatabase.FindByName(gameInfo.Name); + bool databaseChanged; + if (previousGameInfo != null) + { + // Re-importing an already installed title replaces its private + // working copy. This also upgrades an older encrypted copy + // after the importer has normalized it. + gameInfo.Id = previousGameInfo.Id; + databaseChanged = gameDatabase.UpdateGame(gameInfo); + } + else + { + databaseChanged = gameDatabase.AddGame(gameInfo); + } + + if (!databaseChanged) { dialogService.Show(Severity.Error, ButtonType.OK, @@ -303,7 +324,14 @@ private void InstallGame(string path) } catch (Exception ex) { - gameDatabase.RemoveGame(gameInfo); + if (previousGameInfo != null) + { + gameDatabase.UpdateGame(previousGameInfo); + } + else + { + gameDatabase.RemoveGame(gameInfo); + } throw new IOException("Could not finalize the private game copy.", ex); } diff --git a/Assets/Tests/Editor/OnlyfunReliabilityTests.cs b/Assets/Tests/Editor/OnlyfunReliabilityTests.cs index e642dc9..3ce98f3 100644 --- a/Assets/Tests/Editor/OnlyfunReliabilityTests.cs +++ b/Assets/Tests/Editor/OnlyfunReliabilityTests.cs @@ -177,7 +177,7 @@ public void ImportCopiesGameToPrivateDestination() { string source = Path.Combine(temporaryDirectory, "picked.mpn"); string destination = Path.Combine(temporaryDirectory, "__Games", "00000001.mpn"); - byte[] contents = { 1, 2, 3, 4 }; + byte[] contents = CreateMinimalPlainMpn(); File.WriteAllBytes(source, contents); object result = Import(source, destination); @@ -187,6 +187,52 @@ public void ImportCopiesGameToPrivateDestination() Assert.That(File.ReadAllBytes(destination), Is.EqualTo(contents)); } + [Test] + public void ImportRejectsInvalidMpnWithTypedError() + { + string source = Path.Combine(temporaryDirectory, "invalid.mpn"); + File.WriteAllBytes(source, new byte[] { 1, 2, 3, 4 }); + + object result = Import(source, + Path.Combine(temporaryDirectory, "__Games", "00000001.mpn")); + + Assert.That(Property(result, "Succeeded"), Is.False); + Assert.That(Property(result, "ErrorCode").ToString(), Is.EqualTo("InvalidMpn")); + } + + [Test] + public void EncryptedHoneyCaveIsDecryptedAndCachedWithoutChangingSource() + { + string source = Environment.GetEnvironmentVariable("ONLYFUN_TEST_GAME"); + string expected = Environment.GetEnvironmentVariable("ONLYFUN_EXPECTED_DECRYPTED"); + if (string.IsNullOrWhiteSpace(source) || !File.Exists(source) || + string.IsNullOrWhiteSpace(expected) || !File.Exists(expected)) + { + Assert.Ignore("Set ONLYFUN_TEST_GAME and ONLYFUN_EXPECTED_DECRYPTED for the encrypted fixture test."); + } + + string destination = Path.Combine(temporaryDirectory, "__Games", "00000001.mpn"); + string cache = Path.Combine(temporaryDirectory, "__MophunCache"); + byte[] original = File.ReadAllBytes(source); + byte[] expectedBytes = File.ReadAllBytes(expected); + + Type serviceType = RuntimeType("Nofun.Services.GameImportService"); + object service = Activator.CreateInstance(serviceType, new object[] { cache }); + object first = serviceType.GetMethod("Import").Invoke(service, new[] { source, destination }); + + Assert.That(Property(first, "Succeeded"), Is.True); + Assert.That(Property(first, "WasDecrypted"), Is.True); + Assert.That(File.ReadAllBytes(destination), Is.EqualTo(expectedBytes)); + Assert.That(File.ReadAllBytes(source), Is.EqualTo(original)); + + File.Delete(destination); + object second = serviceType.GetMethod("Import").Invoke(service, new[] { source, destination }); + Assert.That(Property(second, "Succeeded"), Is.True); + Assert.That(Property(second, "WasDecrypted"), Is.True); + Assert.That(File.ReadAllBytes(destination), Is.EqualTo(expectedBytes)); + Assert.That(Directory.GetFiles(cache, "*.mpn").Length, Is.EqualTo(1)); + } + [Test] public void ImportRejectsEmptyGameWithTypedError() { @@ -207,6 +253,28 @@ private object Import(string source, string destination) return serviceType.GetMethod("Import").Invoke(service, new[] { source, destination }); } + private static byte[] CreateMinimalPlainMpn() + { + byte[] bytes = new byte[52]; + bytes[0] = (byte)'V'; + bytes[1] = (byte)'M'; + bytes[2] = (byte)'G'; + bytes[3] = (byte)'P'; + WriteUInt32(bytes, 12, 4); + WriteUInt32(bytes, 24, 8); + WriteUInt32(bytes, 40, 0); + WriteUInt32(bytes, 48, 0); + return bytes; + } + + private static void WriteUInt32(byte[] bytes, int offset, uint value) + { + bytes[offset] = (byte)value; + bytes[offset + 1] = (byte)(value >> 8); + bytes[offset + 2] = (byte)(value >> 16); + bytes[offset + 3] = (byte)(value >> 24); + } + private Type RuntimeType(string name) => runtimeAssembly.GetType(name, true); From 734e3d4f074f57f2e9c9e8e1be2c34e69c743424 Mon Sep 17 00:00:00 2001 From: Xurshid Muhammadiyev Date: Wed, 12 Aug 2026 14:26:14 +0500 Subject: [PATCH 12/13] docs: focus fork landing page on Android --- README.md | 60 ++++++++++++++++++++++++++++--------------------------- 1 file changed, 31 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index a61c5a0..854da6f 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,56 @@ -

Nofun Nofun

+# Onlyfun for Android -Nofun contains a Mophun emulator, written in C# and currently run under Unity environment. - -Currently, the emulator still is not mature for many 3D games, and does not support encrypted/compressed. +Onlyfun is an Android-only Mophun emulator built with Unity. This fork focuses on getting legacy Mophun games running on modern Android phones with as little setup as possible. ## Download -It's recommended to download the emulator through the **Releases** section on the project's Github page. +Download the newest Android APK from the [Releases](https://github.com/willlrock/onlyfun/releases) page. + +The current development build targets Android 8.0+ (API 26) and ARM64 devices. It is a test build: install it manually and allow Android to install apps from your browser or file manager when prompted. + +## Import a game -## Screenshots +1. Open Onlyfun and tap **+**. +2. Select the original `.mpn` file. +3. Onlyfun validates it, detects encrypted Mophun code, and decrypts supported files locally when needed. -| The DaVinci Code | Sushi Fighter | -:-------------------------:|:-------------------------: -![The DaVinci Code - PC](https://github.com/RadratSoftworks/nofun/assets/25717050/d881873b-2c12-4b77-91b0-161b1c4c0598) | ![Sushi Fighter - PC](https://github.com/RadratSoftworks/nofun/assets/25717050/e7ca4f63-4611-4833-a1d9-7edfa4b27e8f) +The original file is never modified. Decrypted working copies are stored in the app's private storage and cached by the source file's SHA-256, so the same game is not decrypted again on every import. -| Honey Cave 2 | Rally Pro Contest | -:-------------------------:|:-------------------------: -![Screenshot_2023-05-24-04-17-14-662_com Radrat nofun](https://github.com/RadratSoftworks/nofun/assets/25717050/65c0b87e-0c15-4e59-ae1e-8afde21f4d20) | ![Screenshot_2023-05-24-04-18-09-280_com Radrat nofun](https://github.com/RadratSoftworks/nofun/assets/25717050/c5b8fb07-605b-40b8-939d-47e6b3a6c4f1) +No key files, desktop tools, manual conversion, or extra user steps are required. -## Controls +If a game uses an unsupported Mophun format, Onlyfun shows a readable error and records the technical details in `onlyfun.log`. -- W,A,S,D/arrow keys/DPad: movement -- Gamepad A/Enter/Right mouse: Fire1 -- Gamepad B/Space: Fire2 -- Gamepad Select/Esc/Three bars button on screen: Back +## Honey Cave 2 + +The Android build includes the encryption profile required by the encrypted Honey Cave 2 MPNs commonly found in preservation archives. After import, the game should launch in the same way as an already decrypted copy. ## Game configuration -When launching a game for the first time, a configuration screen is opened. +Open a game's settings before launch, or use the gear button while it is running. Some Sony Ericsson games require a matching phone model and system version 1.30; Honey Cave 2 uses its legacy compatibility profile automatically. + +## Reporting a problem -To access and edit the configuration of a running game again, click/touch on the Cog/Gear/Settings button on the screen. +Please include: -**Note**: for Sony Ericcsion game: -- You may need to select a specific SE phone model in order to run a game (T300/T6x0), else the game will throw the "Terminal not found" error (the game checks for running phone model) -- In addition, you should select System version 1.30 to run Sony Ericssion phone games. +- Android model and OS version; +- the Onlyfun APK version; +- the exact error shown in the app; +- `onlyfun.log` from the export dialog. -## Portablity +Do not upload commercial game files or ROMs to the repository. -The core code in Scripts folder has also been prepared and designed to allow other backends like SDL2 to integrate in. +## Development -## Attributions +This project is intended to be opened with Unity **6000.5.6f1 (Unity 6.5)** and the Android Build Support module. The Android player is built with IL2CPP and API 26 minimum SDK. -Thanks Mr. JaGoTu for providing decompression algorithm. +The encrypted-game importer is organized around profiles, so additional Mophun encryption families can be added without changing the Android import flow. -Thanks Mr. 1upus for helping with games' encryption. +## Credits -Thanks for the effort of Kahvibreak server for preserving needed resources. +Onlyfun is based on the original Nofun project by Radrat Softworks. Thanks to JaGoTu for the decompression work, 1upus for help with Mophun encryption, and the Kahvibreak preservation community for recovered resources. ## License Copyright 2023 Radrat Softworks. -The code is licensed under Apache License 2.0. Visit the [LICENSE](LICENSE) file for more information. +The source code is licensed under the [Apache License 2.0](LICENSE). From e037d411b25a678e2bbc03148164d27bef75d61c Mon Sep 17 00:00:00 2001 From: Xurshid Muhammadiyev Date: Wed, 12 Aug 2026 15:05:10 +0500 Subject: [PATCH 13/13] feat(android): import multipart Mophun game sets --- Assets/Plugins/FilePicker/FilePicker.cs | 59 +++ Assets/Scripts/Services/GameImportResult.cs | 23 +- Assets/Scripts/Services/GameImportService.cs | 418 ++++++++++++++++-- .../Scripts/UI/GameListDocumentController.cs | 103 ++++- .../Tests/Editor/OnlyfunReliabilityTests.cs | 54 +++ README.md | 6 +- 6 files changed, 622 insertions(+), 41 deletions(-) diff --git a/Assets/Plugins/FilePicker/FilePicker.cs b/Assets/Plugins/FilePicker/FilePicker.cs index 1373469..bc47215 100644 --- a/Assets/Plugins/FilePicker/FilePicker.cs +++ b/Assets/Plugins/FilePicker/FilePicker.cs @@ -71,6 +71,65 @@ public static bool OpenPickFileDialog(FilterItem[] filters, Action onPat } #endif + /// + /// Opens a picker for one game set. Android uses the native multi-file + /// picker when available; desktop/editor fall back to a single selection. + /// + public static bool OpenPickFilesDialog(FilterItem[] filters, Action onPathsReceived, string defaultPath = null) + { +#if UNITY_EDITOR + List filterMapped = new(); + foreach (FilterItem filter in filters) + { + filterMapped.Add(filter.name); + filterMapped.Add(filter.spec); + } + + string path = UnityEditor.EditorUtility.OpenFilePanelWithFilters("Select game files", defaultPath ?? "", filterMapped.ToArray()); + onPathsReceived?.Invoke(string.IsNullOrEmpty(path) ? new string[0] : new[] { path }); + return true; +#elif UNITY_STANDALONE_OSX || UNITY_STANDALONE_WIN || UNITY_STANDALONE_LINUX + string path = NativeFileDialog.OpenPickFileDialog(filters, defaultPath); + onPathsReceived?.Invoke(string.IsNullOrEmpty(path) ? new string[0] : new[] { path }); + return true; +#elif UNITY_ANDROID + string[] allowedTypes = filters == null + ? new[] { "*/*" } + : filters.Select(item => item.spec).ToArray(); + + if (NativeFilePicker.CanPickMultipleFiles()) + { + NativeFilePicker.PickMultipleFiles( + (string[] paths) => + { + if (paths == null || paths.Length == 0) + { + Debug.LogWarning("Open multi-file picker was cancelled or permission was denied."); + onPathsReceived?.Invoke(new string[0]); + } + else + { + onPathsReceived?.Invoke(paths); + } + }, + allowedTypes + ); + } + else + { + NativeFilePicker.PickFile( + (string path) => onPathsReceived?.Invoke(string.IsNullOrEmpty(path) ? new string[0] : new[] { path }), + allowedTypes + ); + } + + return true; +#else + onPathsReceived?.Invoke(new string[0]); + return false; +#endif + } + public static void ExportLog(string sourcePath, Action onFinished) { #if UNITY_EDITOR diff --git a/Assets/Scripts/Services/GameImportResult.cs b/Assets/Scripts/Services/GameImportResult.cs index 3d2d3ca..5f0a95e 100644 --- a/Assets/Scripts/Services/GameImportResult.cs +++ b/Assets/Scripts/Services/GameImportResult.cs @@ -15,6 +15,8 @@ public enum GameImportErrorCode PermissionDenied, EmptyFile, InvalidMpn, + MissingMultipartPart, + InvalidInputSet, UnsupportedEncryption, DecryptionFailed, CopyFailed @@ -29,9 +31,15 @@ public readonly struct GameImportResult public Exception Exception { get; } public bool WasDecrypted { get; } public string SourceSha256 { get; } + public bool WasMultipart { get; } + public int MultipartPartCount { get; } + public string ImportedResourceDirectory { get; } + public string[] ImportedResourcePaths { get; } private GameImportResult(bool succeeded, string importedPath, GameImportErrorCode errorCode, - string message, Exception exception, bool wasDecrypted, string sourceSha256) + string message, Exception exception, bool wasDecrypted, string sourceSha256, + bool wasMultipart, int multipartPartCount, string importedResourceDirectory, + string[] importedResourcePaths) { Succeeded = succeeded; ImportedPath = importedPath; @@ -40,12 +48,19 @@ private GameImportResult(bool succeeded, string importedPath, GameImportErrorCod Exception = exception; WasDecrypted = wasDecrypted; SourceSha256 = sourceSha256; + WasMultipart = wasMultipart; + MultipartPartCount = multipartPartCount; + ImportedResourceDirectory = importedResourceDirectory; + ImportedResourcePaths = importedResourcePaths ?? new string[0]; } - public static GameImportResult Success(string importedPath, bool wasDecrypted = false, string sourceSha256 = null) => - new(true, importedPath, GameImportErrorCode.None, null, null, wasDecrypted, sourceSha256); + public static GameImportResult Success(string importedPath, bool wasDecrypted = false, string sourceSha256 = null, + bool wasMultipart = false, int multipartPartCount = 1, + string importedResourceDirectory = null, string[] importedResourcePaths = null) => + new(true, importedPath, GameImportErrorCode.None, null, null, wasDecrypted, sourceSha256, + wasMultipart, multipartPartCount, importedResourceDirectory, importedResourcePaths); public static GameImportResult Failure(GameImportErrorCode errorCode, string message, Exception exception = null) => - new(false, null, errorCode, message, exception, false, null); + new(false, null, errorCode, message, exception, false, null, false, 0, null, null); } } diff --git a/Assets/Scripts/Services/GameImportService.cs b/Assets/Scripts/Services/GameImportService.cs index 68e1d45..a28fc9b 100644 --- a/Assets/Scripts/Services/GameImportService.cs +++ b/Assets/Scripts/Services/GameImportService.cs @@ -5,6 +5,7 @@ */ using System; +using System.Collections.Generic; using System.IO; using System.Security.Cryptography; @@ -13,12 +14,29 @@ namespace Nofun.Services public interface IGameImportService { GameImportResult Import(string sourcePath, string destinationPath); + GameImportResult ImportBundle(string[] sourcePaths, string destinationPath, string resourceDirectory); } public sealed class GameImportService : IGameImportService { private readonly string decryptionCacheDirectory; + private sealed class MultipartPart + { + public string Path; + public int Number; + public int Total; + public string BaseName; + } + + private sealed class InputSet + { + public readonly List MpnPaths = new List(); + public readonly List MpcPaths = new List(); + public bool WasMultipart; + public int MultipartPartCount; + } + public GameImportService() : this(null) { } @@ -30,22 +48,53 @@ public GameImportService(string decryptionCacheDirectory) public GameImportResult Import(string sourcePath, string destinationPath) { - if (string.IsNullOrWhiteSpace(sourcePath) || !File.Exists(sourcePath)) + return ImportBundle(new[] { sourcePath }, destinationPath, null); + } + + /// + /// Imports one MPN or a complete numbered multipart MPN set. Related MPC + /// files are copied to a temporary resource directory and finalized by the + /// game-list controller after it has read the game's title. + /// + public GameImportResult ImportBundle(string[] sourcePaths, string destinationPath, string resourceDirectory) + { + InputSet inputSet; + string inputError; + GameImportErrorCode inputErrorCode; + try + { + if (!TryBuildInputSet(sourcePaths, out inputSet, out inputErrorCode, out inputError)) + { + return GameImportResult.Failure(inputErrorCode, inputError); + } + } + catch (UnauthorizedAccessException ex) { - return GameImportResult.Failure(GameImportErrorCode.SourceUnavailable, - "The selected game is no longer available."); + return GameImportResult.Failure(GameImportErrorCode.PermissionDenied, + "Onlyfun does not have permission to inspect the selected game set.", ex); + } + catch (Exception ex) + { + return GameImportResult.Failure(GameImportErrorCode.InvalidInputSet, + "Onlyfun could not inspect the selected game set.", ex); } try { - var sourceInfo = new FileInfo(sourcePath); - if (sourceInfo.Length == 0) + string destinationParent = Path.GetDirectoryName(destinationPath); + if (string.IsNullOrEmpty(destinationParent)) + { + return GameImportResult.Failure(GameImportErrorCode.CopyFailed, + "Onlyfun could not determine where to store the game."); + } + + byte[] sourceBytes = ReadCombinedMpn(inputSet.MpnPaths); + if (sourceBytes.Length == 0) { return GameImportResult.Failure(GameImportErrorCode.EmptyFile, "The selected game file is empty."); } - byte[] sourceBytes = File.ReadAllBytes(sourcePath); string sourceSha256 = ComputeSha256(sourceBytes); byte[] normalizedBytes; bool wasDecrypted; @@ -88,49 +137,329 @@ public GameImportResult Import(string sourcePath, string destinationPath) } } - string destinationDirectory = Path.GetDirectoryName(destinationPath); - if (string.IsNullOrEmpty(destinationDirectory)) + Directory.CreateDirectory(destinationParent); + WriteAtomically(destinationPath, normalizedBytes); + + string copiedResourceDirectory = null; + string[] copiedResourcePaths = new string[0]; + if (inputSet.MpcPaths.Count > 0) { - return GameImportResult.Failure(GameImportErrorCode.CopyFailed, - "Onlyfun could not determine where to store the game."); + if (string.IsNullOrEmpty(resourceDirectory)) + { + return GameImportResult.Failure(GameImportErrorCode.CopyFailed, + "Onlyfun could not determine where to store the related MPC resources."); + } + + copiedResourceDirectory = resourceDirectory; + Directory.CreateDirectory(copiedResourceDirectory); + List copied = new List(); + foreach (string sourcePath in inputSet.MpcPaths) + { + string fileName = Path.GetFileName(sourcePath); + string targetPath = Path.Combine(copiedResourceDirectory, fileName); + WriteAtomicallyFromFile(sourcePath, targetPath); + copied.Add(targetPath); + } + + copiedResourcePaths = copied.ToArray(); + } + + return GameImportResult.Success(destinationPath, wasDecrypted, sourceSha256, + inputSet.WasMultipart, inputSet.MultipartPartCount, + copiedResourceDirectory, copiedResourcePaths); + } + catch (UnauthorizedAccessException ex) + { + return GameImportResult.Failure(GameImportErrorCode.PermissionDenied, + "Onlyfun does not have permission to read the selected file.", ex); + } + catch (Exception ex) + { + return GameImportResult.Failure(GameImportErrorCode.CopyFailed, + "Onlyfun could not import the selected game set.", ex); + } + } + + private static bool TryBuildInputSet(string[] sourcePaths, out InputSet inputSet, + out GameImportErrorCode errorCode, out string error) + { + inputSet = new InputSet(); + errorCode = GameImportErrorCode.None; + error = null; + + if (sourcePaths == null || sourcePaths.Length == 0) + { + errorCode = GameImportErrorCode.SourceUnavailable; + error = "No game files were selected."; + return false; + } + + List selectedMpn = new List(); + List selectedMpc = new List(); + HashSet seenPaths = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (string sourcePath in sourcePaths) + { + if (string.IsNullOrWhiteSpace(sourcePath) || !File.Exists(sourcePath)) + { + errorCode = GameImportErrorCode.SourceUnavailable; + error = "One of the selected files is no longer available."; + return false; + } + + string fullPath = Path.GetFullPath(sourcePath); + if (!seenPaths.Add(fullPath)) + { + continue; + } + + string extension = Path.GetExtension(fullPath); + if (string.Equals(extension, ".mpn", StringComparison.OrdinalIgnoreCase)) + { + selectedMpn.Add(fullPath); + } + else if (string.Equals(extension, ".mpc", StringComparison.OrdinalIgnoreCase)) + { + selectedMpc.Add(fullPath); + } + else + { + errorCode = GameImportErrorCode.InvalidInputSet; + error = "Select a Mophun .mpn game, its numbered .mpn parts, and optional .mpc resources."; + return false; + } + } + + if (selectedMpn.Count == 0) + { + errorCode = GameImportErrorCode.InvalidInputSet; + error = "Select at least one .mpn game file. Related .mpc files can be selected with it."; + return false; + } + + List parts = new List(); + foreach (string path in selectedMpn) + { + MultipartPart part; + if (TryParseMultipartName(path, out part)) + { + parts.Add(part); + } + } + + if (parts.Count > 0) + { + MultipartPart first = parts[0]; + for (int i = 1; i < parts.Count; i++) + { + if (parts[i].Total != first.Total || + !string.Equals(parts[i].BaseName, first.BaseName, StringComparison.OrdinalIgnoreCase)) + { + errorCode = GameImportErrorCode.InvalidInputSet; + error = "The selected numbered MPN parts belong to different multipart games."; + return false; + } + } + + // On desktop, selecting one part can still discover its siblings. + // Android providers often expose only the copied selections, so the + // multi-file picker remains the portable fallback. + HashSet selectedPartPaths = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (MultipartPart part in parts) + { + selectedPartPaths.Add(Path.GetFullPath(part.Path)); + } + + foreach (string directory in DistinctDirectories(selectedMpn)) + { + string[] candidates = Directory.GetFiles(directory, "*.mpn"); + foreach (string candidate in candidates) + { + MultipartPart candidatePart; + if (!TryParseMultipartName(candidate, out candidatePart) || + candidatePart.Total != first.Total || + !string.Equals(candidatePart.BaseName, first.BaseName, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (selectedPartPaths.Add(Path.GetFullPath(candidate))) + { + parts.Add(candidatePart); + } + } } - Directory.CreateDirectory(destinationDirectory); - string temporaryPath = destinationPath + ".importing"; + Dictionary byNumber = new Dictionary(); + foreach (MultipartPart part in parts) + { + if (byNumber.ContainsKey(part.Number)) + { + errorCode = GameImportErrorCode.InvalidInputSet; + error = "The multipart selection contains a duplicate MPN part."; + return false; + } - try + byNumber.Add(part.Number, part); + } + + List ordered = new List(); + List missing = new List(); + for (int number = 1; number <= first.Total; number++) { - using (var destination = new FileStream(temporaryPath, FileMode.Create, FileAccess.Write, FileShare.None)) + MultipartPart part; + if (!byNumber.TryGetValue(number, out part)) { - destination.Write(normalizedBytes, 0, normalizedBytes.Length); - destination.Flush(); + missing.Add(number); } + else + { + ordered.Add(part.Path); + } + } - if (File.Exists(destinationPath)) + if (missing.Count > 0) + { + errorCode = GameImportErrorCode.MissingMultipartPart; + error = $"This looks like a multipart MPN set, but part(s) {string.Join(", ", missing)} of {first.Total} are missing. Select all files named 1_{first.Total}_... through {first.Total}_{first.Total}_... together."; + return false; + } + + inputSet.MpnPaths.AddRange(ordered); + inputSet.WasMultipart = first.Total > 1; + inputSet.MultipartPartCount = first.Total; + + // A locally extracted multipart set commonly keeps its MPC files + // beside the MPN parts. Include those automatically when possible. + foreach (string directory in DistinctDirectories(inputSet.MpnPaths)) + { + foreach (string candidate in Directory.GetFiles(directory, "*.mpc")) { - File.Delete(destinationPath); + if (!ContainsPath(selectedMpc, candidate)) + { + selectedMpc.Add(candidate); + } } + } + } + else + { + if (selectedMpn.Count != 1) + { + errorCode = GameImportErrorCode.InvalidInputSet; + error = "Select one Mophun game or all parts of one numbered multipart game."; + return false; + } + + inputSet.MpnPaths.Add(selectedMpn[0]); + inputSet.WasMultipart = false; + inputSet.MultipartPartCount = 1; + } - File.Move(temporaryPath, destinationPath); - return GameImportResult.Success(destinationPath, wasDecrypted, sourceSha256); + foreach (string mpcPath in selectedMpc) + { + string fileName = Path.GetFileName(mpcPath); + if (string.IsNullOrEmpty(fileName)) + { + errorCode = GameImportErrorCode.InvalidInputSet; + error = "One of the selected MPC resources has no file name."; + return false; } - finally + + for (int i = 0; i < inputSet.MpcPaths.Count; i++) { - if (File.Exists(temporaryPath)) + if (string.Equals(Path.GetFileName(inputSet.MpcPaths[i]), fileName, + StringComparison.OrdinalIgnoreCase)) { - File.Delete(temporaryPath); + errorCode = GameImportErrorCode.InvalidInputSet; + error = $"The selected resource set contains duplicate MPC file name '{fileName}'."; + return false; } } + + inputSet.MpcPaths.Add(mpcPath); } - catch (UnauthorizedAccessException ex) + + return true; + } + + private static IEnumerable DistinctDirectories(IEnumerable paths) + { + HashSet seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (string path in paths) { - return GameImportResult.Failure(GameImportErrorCode.PermissionDenied, - "Onlyfun does not have permission to read the selected file.", ex); + string directory = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directory) && seen.Add(directory)) + { + yield return directory; + } } - catch (Exception ex) + } + + private static bool ContainsPath(List paths, string candidate) + { + string fullCandidate = Path.GetFullPath(candidate); + for (int i = 0; i < paths.Count; i++) { - return GameImportResult.Failure(GameImportErrorCode.CopyFailed, - "Onlyfun could not copy the selected game into its library.", ex); + if (string.Equals(Path.GetFullPath(paths[i]), fullCandidate, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private static bool TryParseMultipartName(string path, out MultipartPart part) + { + part = null; + string fileName = Path.GetFileNameWithoutExtension(path); + int firstSeparator = fileName.IndexOf('_'); + if (firstSeparator <= 0) + { + return false; + } + + int secondSeparator = fileName.IndexOf('_', firstSeparator + 1); + if (secondSeparator <= firstSeparator + 1 || secondSeparator == fileName.Length - 1) + { + return false; + } + + int number; + int total; + if (!int.TryParse(fileName.Substring(0, firstSeparator), out number) || + !int.TryParse(fileName.Substring(firstSeparator + 1, secondSeparator - firstSeparator - 1), out total) || + number < 1 || total < number) + { + return false; + } + + part = new MultipartPart + { + Path = path, + Number = number, + Total = total, + BaseName = fileName.Substring(secondSeparator + 1) + }; + return true; + } + + private static byte[] ReadCombinedMpn(List paths) + { + if (paths.Count == 1) + { + return File.ReadAllBytes(paths[0]); + } + + using (MemoryStream combined = new MemoryStream()) + { + foreach (string path in paths) + { + byte[] bytes = File.ReadAllBytes(path); + combined.Write(bytes, 0, bytes.Length); + } + + return combined.ToArray(); } } @@ -159,6 +488,12 @@ private static void WriteAtomically(string path, byte[] bytes) string temporaryPath = path + ".importing"; try { + string parent = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(parent)) + { + Directory.CreateDirectory(parent); + } + using (var stream = new FileStream(temporaryPath, FileMode.Create, FileAccess.Write, FileShare.None)) { stream.Write(bytes, 0, bytes.Length); @@ -178,6 +513,31 @@ private static void WriteAtomically(string path, byte[] bytes) } } + private static void WriteAtomicallyFromFile(string sourcePath, string destinationPath) + { + string temporaryPath = destinationPath + ".importing"; + try + { + using (FileStream source = new FileStream(sourcePath, FileMode.Open, FileAccess.Read, FileShare.Read)) + using (FileStream destination = new FileStream(temporaryPath, FileMode.Create, FileAccess.Write, FileShare.None)) + { + source.CopyTo(destination); + destination.Flush(); + } + + if (File.Exists(destinationPath)) + { + File.Delete(destinationPath); + } + + File.Move(temporaryPath, destinationPath); + } + finally + { + TryDelete(temporaryPath); + } + } + private static void TryDelete(string path) { try diff --git a/Assets/Scripts/UI/GameListDocumentController.cs b/Assets/Scripts/UI/GameListDocumentController.cs index 32c8452..47e3432 100644 --- a/Assets/Scripts/UI/GameListDocumentController.cs +++ b/Assets/Scripts/UI/GameListDocumentController.cs @@ -23,6 +23,7 @@ using Nofun.Parser; using Nofun.Services; using Nofun.Plugins; +using Nofun.Util; using UnityEngine; using UnityEngine.UIElements; using VContainer; @@ -199,23 +200,65 @@ private void RemoveGame(GameInfo gameInfo) File.Delete(gamePath); } + DeleteDirectory(GetGameResourcePath(gameInfo)); + gameDatabase.RemoveGame(gameInfo); LoadGameList(); } private void InstallGame(string path) { - if (string.IsNullOrEmpty(path)) + InstallGame(string.IsNullOrEmpty(path) ? null : new[] { path }); + } + + private string GetGameResourcePath(GameInfo gameInfo) + { + return Path.Combine(Application.persistentDataPath, gameInfo.Name.ToValidFileName()); + } + + private static void DeleteDirectory(string path) + { + try + { + if (!string.IsNullOrEmpty(path) && Directory.Exists(path)) + { + Directory.Delete(path, true); + } + } + catch (Exception ex) + { + Util.Logging.Logger.Warning(Util.Logging.LogClass.Loader, + $"Could not remove game resource directory '{path}': {ex}"); + } + } + + private void InstallGame(string[] paths) + { + if (paths == null || paths.Length == 0) { return; } string stagedPath = Path.Combine(GamePathRoot, $".{Guid.NewGuid():N}.mpn"); - GameImportResult importResult = gameImportService.Import(path, stagedPath); + string stagedResourceDirectory = Path.Combine(GamePathRoot, $".{Guid.NewGuid():N}.resources"); + GameImportResult importResult = gameImportService.ImportBundle(paths, stagedPath, stagedResourceDirectory); if (!importResult.Succeeded) { Util.Logging.Logger.Error(Util.Logging.LogClass.Loader, $"Game import failed ({importResult.ErrorCode}): {importResult.Message}\n{importResult.Exception}"); + try + { + if (File.Exists(stagedPath)) + { + File.Delete(stagedPath); + } + DeleteDirectory(stagedResourceDirectory); + } + catch (Exception cleanupException) + { + Util.Logging.Logger.Warning(Util.Logging.LogClass.Loader, + $"Could not remove failed staged game import: {cleanupException}"); + } dialogService.Show(Severity.Error, ButtonType.OK, translationService.Translate("Error"), @@ -230,6 +273,18 @@ private void InstallGame(string path) $"Encrypted Mophun code was decrypted locally (source SHA-256 {importResult.SourceSha256})."); } + if (importResult.WasMultipart) + { + Util.Logging.Logger.Debug(Util.Logging.LogClass.Loader, + $"Assembled multipart MPN set ({importResult.MultipartPartCount} parts)."); + } + + if (importResult.ImportedResourcePaths != null && importResult.ImportedResourcePaths.Length > 0) + { + Util.Logging.Logger.Debug(Util.Logging.LogClass.Loader, + $"Imported {importResult.ImportedResourcePaths.Length} related MPC resource(s)."); + } + try { GameInfo gameInfo; @@ -313,6 +368,11 @@ private void InstallGame(string path) } string gamePath = GetGamePath(gameInfo); + string gameResourcePath = GetGameResourcePath(gameInfo); + bool shouldMoveResources = importResult.ImportedResourcePaths != null && + importResult.ImportedResourcePaths.Length > 0; + bool gameWasMoved = false; + bool resourcesWereMoved = false; try { if (File.Exists(gamePath)) @@ -321,9 +381,33 @@ private void InstallGame(string path) } File.Move(stagedPath, gamePath); + gameWasMoved = true; + + if (shouldMoveResources) + { + DeleteDirectory(gameResourcePath); + Directory.Move(importResult.ImportedResourceDirectory, gameResourcePath); + resourcesWereMoved = true; + } } catch (Exception ex) { + if (resourcesWereMoved) + { + DeleteDirectory(gameResourcePath); + } + if (gameWasMoved && File.Exists(gamePath)) + { + try + { + File.Delete(gamePath); + } + catch + { + // Keep the original finalization exception as the user-facing error. + } + } + if (previousGameInfo != null) { gameDatabase.UpdateGame(previousGameInfo); @@ -361,6 +445,8 @@ private void InstallGame(string path) { File.Delete(stagedPath); } + + DeleteDirectory(stagedResourceDirectory); } catch (Exception ex) { @@ -372,13 +458,18 @@ private void InstallGame(string path) private void OnInstallButtonClicked() { - bool permissionGranted = FilePicker.OpenPickFileDialog(new FilterItem[] + bool permissionGranted = FilePicker.OpenPickFilesDialog(new FilterItem[] { #if UNITY_EDITOR || !UNITY_ANDROID new FilterItem { name = "Mophun game", spec = "mpn" + }, + new FilterItem + { + name = "Mophun resource", + spec = "mpc" } #else new FilterItem @@ -392,11 +483,11 @@ private void OnInstallButtonClicked() spec = "*/*" } #endif - }, (string path) => + }, (string[] paths) => { - if (!string.IsNullOrEmpty(path)) + if (paths != null && paths.Length > 0) { - InstallGame(path); + InstallGame(paths); } }); diff --git a/Assets/Tests/Editor/OnlyfunReliabilityTests.cs b/Assets/Tests/Editor/OnlyfunReliabilityTests.cs index 3ce98f3..8fa8321 100644 --- a/Assets/Tests/Editor/OnlyfunReliabilityTests.cs +++ b/Assets/Tests/Editor/OnlyfunReliabilityTests.cs @@ -200,6 +200,53 @@ public void ImportRejectsInvalidMpnWithTypedError() Assert.That(Property(result, "ErrorCode").ToString(), Is.EqualTo("InvalidMpn")); } + [Test] + public void MultipartMpnPartsAreAssembledAndMpcResourcesCopied() + { + string firstPart = Path.Combine(temporaryDirectory, "1_2_TestGame.mpn"); + string secondPart = Path.Combine(temporaryDirectory, "2_2_TestGame.mpn"); + string resource = Path.Combine(temporaryDirectory, "TestGame_extrapack.mpc"); + string destination = Path.Combine(temporaryDirectory, "__Games", "00000001.mpn"); + string resourceDirectory = Path.Combine(temporaryDirectory, "__Resources"); + byte[] original = CreateMinimalPlainMpn(); + + File.WriteAllBytes(firstPart, Slice(original, 0, 19)); + File.WriteAllBytes(secondPart, Slice(original, 19, original.Length - 19)); + File.WriteAllBytes(resource, new byte[] { 0x4D, 0x50, 0x43, 0x01, 0x02 }); + + Type serviceType = RuntimeType("Nofun.Services.GameImportService"); + object service = Activator.CreateInstance(serviceType); + object result = serviceType.GetMethod("ImportBundle").Invoke(service, + new object[] { new[] { secondPart, firstPart, resource }, destination, resourceDirectory }); + + Assert.That(Property(result, "Succeeded"), Is.True); + Assert.That(Property(result, "WasMultipart"), Is.True); + Assert.That(Property(result, "MultipartPartCount"), Is.EqualTo(2)); + Assert.That(File.ReadAllBytes(destination), Is.EqualTo(original)); + + string copiedResource = Path.Combine(resourceDirectory, Path.GetFileName(resource)); + Assert.That(File.Exists(copiedResource), Is.True); + Assert.That(File.ReadAllBytes(copiedResource), Is.EqualTo(File.ReadAllBytes(resource))); + Assert.That((string[])Property(result, "ImportedResourcePaths"), Is.EqualTo(new[] { copiedResource })); + } + + [Test] + public void MultipartMpnReportsMissingPartWithoutWritingOutput() + { + string part = Path.Combine(temporaryDirectory, "1_3_Incomplete.mpn"); + string destination = Path.Combine(temporaryDirectory, "__Games", "00000001.mpn"); + File.WriteAllBytes(part, new byte[] { 1, 2, 3 }); + + Type serviceType = RuntimeType("Nofun.Services.GameImportService"); + object service = Activator.CreateInstance(serviceType); + object result = serviceType.GetMethod("ImportBundle").Invoke(service, + new object[] { new[] { part }, destination, Path.Combine(temporaryDirectory, "__Resources") }); + + Assert.That(Property(result, "Succeeded"), Is.False); + Assert.That(Property(result, "ErrorCode").ToString(), Is.EqualTo("MissingMultipartPart")); + Assert.That(File.Exists(destination), Is.False); + } + [Test] public void EncryptedHoneyCaveIsDecryptedAndCachedWithoutChangingSource() { @@ -275,6 +322,13 @@ private static void WriteUInt32(byte[] bytes, int offset, uint value) bytes[offset + 3] = (byte)(value >> 24); } + private static byte[] Slice(byte[] bytes, int offset, int count) + { + byte[] result = new byte[count]; + Buffer.BlockCopy(bytes, offset, result, 0, count); + return result; + } + private Type RuntimeType(string name) => runtimeAssembly.GetType(name, true); diff --git a/README.md b/README.md index 854da6f..fd4a48c 100644 --- a/README.md +++ b/README.md @@ -11,13 +11,15 @@ The current development build targets Android 8.0+ (API 26) and ARM64 devices. I ## Import a game 1. Open Onlyfun and tap **+**. -2. Select the original `.mpn` file. -3. Onlyfun validates it, detects encrypted Mophun code, and decrypts supported files locally when needed. +2. Select the original `.mpn` file. If the game is split into numbered parts such as `1_4_Game.mpn` … `4_4_Game.mpn`, select all parts in the same picker operation. You can select its related `.mpc` resource packs at the same time. +3. Onlyfun assembles multipart MPNs in numeric order, imports the MPC resources into the game's private folder, validates the result, and detects encrypted Mophun code before decrypting supported files locally when needed. The original file is never modified. Decrypted working copies are stored in the app's private storage and cached by the source file's SHA-256, so the same game is not decrypted again on every import. No key files, desktop tools, manual conversion, or extra user steps are required. +If only one part of a multipart game is selected and the other parts are not available to the Android file provider, Onlyfun reports which parts are missing instead of trying to launch a broken file. + If a game uses an unsupported Mophun format, Onlyfun shows a readable error and records the technical details in `onlyfun.log`. ## Honey Cave 2