diff --git a/Assets/Plugins/FilePicker/FilePicker.cs b/Assets/Plugins/FilePicker/FilePicker.cs index f3da41e..bc47215 100644 --- a/Assets/Plugins/FilePicker/FilePicker.cs +++ b/Assets/Plugins/FilePicker/FilePicker.cs @@ -54,19 +54,128 @@ 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) + ); + + return true; + } +#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()) { - Debug.LogError("Open file picker permission denied!"); - return false; + 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 { - return true; + 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 + 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, success => onFinished?.Invoke(success)); +#else + onFinished?.Invoke(false); #endif + } } } diff --git a/Assets/Plugins/NativeFileDialog/NativeFileDialog.cs b/Assets/Plugins/NativeFileDialog/NativeFileDialog.cs index 11b4ea7..173e6b4 100644 --- a/Assets/Plugins/NativeFileDialog/NativeFileDialog.cs +++ b/Assets/Plugins/NativeFileDialog/NativeFileDialog.cs @@ -109,6 +109,87 @@ 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; + IntPtr defaultPathPtr = IntPtr.Zero; + IntPtr defaultNamePtr = IntPtr.Zero; + IntPtr outPath = IntPtr.Zero; + + try + { + 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(); + } + + if (defaultPath != null) + { + defaultPathPtr = StringToMarshalledUtf8(defaultPath); + } + + if (defaultName != null) + { + defaultNamePtr = StringToMarshalledUtf8(defaultName); + } + + int result = NFD_SaveDialogU8(out outPath, filterList, + filters == null ? 0 : (uint)filters.Length, defaultPathPtr, defaultNamePtr); + + return result == NFD_RESULT_OK ? Marshal.PtrToStringUTF8(outPath) : null; + } + finally + { + if (outPath != IntPtr.Zero) + { + NFD_FreePathU8(outPath); + } + + if (defaultPathPtr != IntPtr.Zero) + { + FreeMarshalledUtf8(defaultPathPtr); + } + + if (defaultNamePtr != IntPtr.Zero) + { + 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) + { + filterListHandle.Free(); + } + } + } } } -#endif \ No newline at end of file +#endif 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/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 307eb58..7509747 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,8 +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; @@ -95,16 +98,21 @@ 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() { + isDestroying = true; settingDocument.Finished -= FinishSettingDocument; settingDocument.ExitGameRequested -= HandleExitGame; - - if (system != null) + if (StopAndJoinSystemThread()) { - system.Stop(); + Reset(); } } @@ -148,7 +156,7 @@ private void HandleExitGame() settingActive = false; JobScheduler.Paused = false; - system.Stop(); + system?.Stop(); } private void OpenGameSetting() @@ -187,14 +195,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(); @@ -215,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.ReadWrite, 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; } @@ -261,102 +283,220 @@ 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 _) + catch (System.Exception ex) { - dialogService.Show(Severity.Info, ButtonType.OK, - null, - translationService.Translate("Error_Description_GameNotCompatible"), - value => Application.Quit()); + Util.Logging.Logger.Error(Util.Logging.LogClass.Loader, + $"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; + } - failed = true; + systemThread = null; + return true; + } - return; + 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; + + if (executable != null) + { + executable.Dispose(); + executable = null; + } + else + { + gameStream?.Dispose(); + } + } + 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, VMSystem.GetSuitableDefaultSetting(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; + ShowGameFailureDialog(ex); + } - if (settingManager.Get(system.GameName) == null) + private void ShowGameFailureDialog(System.Exception failure) + { + string description = translationService.Translate("Error_Description_GameNotCompatible"); + if (failure != null && !string.IsNullOrWhiteSpace(failure.Message)) { - OpenGameSetting(); + description += $"\n\nDetails: {failure.Message}"; } - systemThread = new Thread(new ThreadStart(() => + dialogService.Show(Severity.Error, ButtonType.YesNo, + null, + description + "\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) + { + try { - system.PostInitialize(); - llvmPrepared = true; + executable = new VMGPExecutable(gameStream); + system = new VMSystem(executable, new VMSystemCreateParameters(graphicDriver, inputDriver, audioDriver, timeDriver, uiDriver, + Application.persistentDataPath, targetExecutable, enableLLVM)); + + settingDocument.Setup(settingManager, system.GameName, + GameProfileResolver.Resolve(system.GameName, system.Executable)); - while (!system.ShouldStop) + settingDocument.Finished -= FinishSettingDocument; + settingDocument.ExitGameRequested -= HandleExitGame; + settingDocument.Finished += FinishSettingDocument; + settingDocument.ExitGameRequested += HandleExitGame; + + 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) + { + ShowGameFailureDialog(failure); + } } private IEnumerator InitializeGameRun() { - GameSetting? setting = settingManager.Get(system.GameName); - setting = setting ?? VMSystem.GetSuitableDefaultSetting(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) { @@ -370,9 +510,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..2a0bec9 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; @@ -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/Services/GameImportResult.cs b/Assets/Scripts/Services/GameImportResult.cs new file mode 100644 index 0000000..5f0a95e --- /dev/null +++ b/Assets/Scripts/Services/GameImportResult.cs @@ -0,0 +1,66 @@ +/* + * (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, + InvalidMpn, + MissingMultipartPart, + InvalidInputSet, + UnsupportedEncryption, + DecryptionFailed, + 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; } + 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, + bool wasMultipart, int multipartPartCount, string importedResourceDirectory, + string[] importedResourcePaths) + { + Succeeded = succeeded; + ImportedPath = importedPath; + ErrorCode = errorCode; + Message = message; + 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, + 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, false, 0, null, null); + } +} 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..a28fc9b --- /dev/null +++ b/Assets/Scripts/Services/GameImportService.cs @@ -0,0 +1,556 @@ +/* + * (C) 2023 Radrat Softworks + * + * Licensed under the Apache License, Version 2.0 (the "License"); + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Security.Cryptography; + +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) + { + } + + public GameImportService(string decryptionCacheDirectory) + { + this.decryptionCacheDirectory = decryptionCacheDirectory; + } + + public GameImportResult Import(string sourcePath, string destinationPath) + { + 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.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 + { + 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."); + } + + 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); + } + } + + Directory.CreateDirectory(destinationParent); + WriteAtomically(destinationPath, normalizedBytes); + + string copiedResourceDirectory = null; + string[] copiedResourcePaths = new string[0]; + if (inputSet.MpcPaths.Count > 0) + { + 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); + } + } + } + + 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; + } + + byNumber.Add(part.Number, part); + } + + List ordered = new List(); + List missing = new List(); + for (int number = 1; number <= first.Total; number++) + { + MultipartPart part; + if (!byNumber.TryGetValue(number, out part)) + { + missing.Add(number); + } + else + { + ordered.Add(part.Path); + } + } + + 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")) + { + 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; + } + + 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; + } + + for (int i = 0; i < inputSet.MpcPaths.Count; i++) + { + if (string.Equals(Path.GetFileName(inputSet.MpcPaths[i]), fileName, + StringComparison.OrdinalIgnoreCase)) + { + errorCode = GameImportErrorCode.InvalidInputSet; + error = $"The selected resource set contains duplicate MPC file name '{fileName}'."; + return false; + } + } + + inputSet.MpcPaths.Add(mpcPath); + } + + return true; + } + + private static IEnumerable DistinctDirectories(IEnumerable paths) + { + HashSet seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (string path in paths) + { + string directory = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directory) && seen.Add(directory)) + { + yield return directory; + } + } + } + + private static bool ContainsPath(List paths, string candidate) + { + string fullCandidate = Path.GetFullPath(candidate); + for (int i = 0; i < paths.Count; i++) + { + 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(); + } + } + + 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 + { + 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); + stream.Flush(); + } + + if (File.Exists(path)) + { + File.Delete(path); + } + + File.Move(temporaryPath, path); + } + finally + { + TryDelete(temporaryPath); + } + } + + 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 + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch + { + // A stale cache can be rebuilt on the next import. + } + } + } +} 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/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/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..9d939f5 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 == 240 && setting.screenSizeY == 320 && + setting.systemVersion == SystemVersion.Version150) + { + 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/UI/GameListDocumentController.cs b/Assets/Scripts/UI/GameListDocumentController.cs index d622480..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; @@ -54,6 +55,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 +81,7 @@ public override void Awake() } gameDatabase = new GameDatabase(GameDatabasePath); + gameImportService = new GameImportService(Path.Combine(Application.persistentDataPath, "__MophunCache")); dynamicIconsProvider = new DynamicIconsProvider(dynamicIconRendererContainer); Directory.CreateDirectory(GamePathRoot); @@ -135,9 +138,10 @@ private void OnGameIconClicked(string gameFileName) } runner.gameObject.SetActive(true); - runner.Launch(gamePath); - - ImmediateHide(); + if (runner.Launch(gamePath)) + { + ImmediateHide(); + } } } @@ -196,23 +200,97 @@ 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; } - using (var executableFile = File.OpenRead(path)) + string stagedPath = Path.Combine(GamePathRoot, $".{Guid.NewGuid():N}.mpn"); + 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 { - VMGPExecutable executable = new VMGPExecutable(executableFile); + 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"), + importResult.Message, + null); + return; + } + + if (importResult.WasDecrypted) + { + Util.Logging.Logger.Debug(Util.Logging.LogClass.Loader, + $"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; + using (var executableFile = File.OpenRead(stagedPath)) + using (VMGPExecutable executable = new VMGPExecutable(executableFile)) + { VMMetaInfoReader metaInfoReader = executable.GetMetaInfo(); if (metaInfoReader == null) { @@ -250,80 +328,178 @@ 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)) + 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, + translationService.Translate("Error"), + translationService.Translate("Error_Description_GameAlreadyInstalled"), + null); + + return; + } + + 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)) { - dialogService.Show(Severity.Error, - ButtonType.OK, - translationService.Translate("Error"), - translationService.Translate("Error_Description_GameAlreadyInstalled"), - null); + File.Delete(gamePath); + } - return; + 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); } else { - // Save the game into the persistent data folder - string gamePath = GetGamePath(gameInfo); - File.Copy(path, gamePath, true); + 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); + 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); } + + DeleteDirectory(stagedResourceDirectory); } catch (Exception 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}"); } } } 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 { name = "Mophun game", spec = "application/octet-stream" + }, + new FilterItem + { + name = "Mophun game (unknown type)", + spec = "*/*" } #endif - }, (string path) => + }, (string[] paths) => { - if (!string.IsNullOrEmpty(path)) + if (paths != null && paths.Length > 0) { - InstallGame(path); + InstallGame(paths); } }); 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); } } 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/Assets/Scripts/VM/VMSystem.cs b/Assets/Scripts/VM/VMSystem.cs index aa39592..fed8d62 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() @@ -294,10 +294,10 @@ public void Run() { processor.Run(InstructionPerRun); } - catch (Exception ex) + catch { shouldStop = true; - throw ex; + throw; } inputDriver.EndFrame(); @@ -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; diff --git a/Assets/Tests.meta b/Assets/Tests.meta new file mode 100644 index 0000000..f3d2c1a --- /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..c9a5480 --- /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/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 new file mode 100644 index 0000000..8fa8321 --- /dev/null +++ b/Assets/Tests/Editor/OnlyfunReliabilityTests.cs @@ -0,0 +1,354 @@ +/* + * (C) 2023 Radrat Softworks + * + * Licensed under the Apache License, Version 2.0 (the "License"); + */ + +using System; +using System.IO; +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); + } + + [TearDown] + public void TearDown() + { + Directory.Delete(temporaryDirectory, true); + } + + [TestCase("HoneyCave2")] + [TestCase("Honey Cave 2")] + [TestCase("honey cave 2")] + public void HoneyCave2GetsExactLegacyProfile(string title) + { + 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] + 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() + { + string source = Path.Combine(temporaryDirectory, "picked.mpn"); + string destination = Path.Combine(temporaryDirectory, "__Games", "00000001.mpn"); + byte[] contents = CreateMinimalPlainMpn(); + File.WriteAllBytes(source, contents); + + object result = Import(source, destination); + + Assert.That(Property(result, "Succeeded"), Is.True); + Assert.That(Property(result, "ImportedPath"), Is.EqualTo(destination)); + 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 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() + { + 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() + { + string source = Path.Combine(temporaryDirectory, "empty.mpn"); + File.WriteAllBytes(source, new byte[0]); + + 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("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 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 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); + + 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")); + 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)); + } + } +} 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 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. diff --git a/README.md b/README.md index a61c5a0..fd4a48c 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,58 @@ -

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. 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 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 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. -- 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 +If a game uses an unsupported Mophun format, Onlyfun shows a readable error and records the technical details in `onlyfun.log`. + +## 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).