diff --git a/Refresher.Core/Accessors/PatchAccessor.cs b/Refresher.Core/Accessors/PatchAccessor.cs index 905d4c4..eb41a1c 100644 --- a/Refresher.Core/Accessors/PatchAccessor.cs +++ b/Refresher.Core/Accessors/PatchAccessor.cs @@ -1,3 +1,6 @@ +using System.Diagnostics.Contracts; +using System.Reflection; + namespace Refresher.Core.Accessors; public abstract class PatchAccessor @@ -38,4 +41,27 @@ public virtual void CopyFile(string inPath, string outPath) inStream.CopyTo(outStream); } + + public static void Try(Action action) + { + try + { + action(); + } + catch (TargetInvocationException targetInvocationException) + { + CatchAccessorException(targetInvocationException.InnerException!); + throw; + } + catch (Exception ex) + { + CatchAccessorException(ex); + throw; + } + } + + private static void CatchAccessorException(Exception ex) + { + State.Logger.LogError(Accessor, ex.ToString()); + } } \ No newline at end of file diff --git a/Refresher.Core/LogType.cs b/Refresher.Core/LogType.cs index ab73a9f..af0bfec 100644 --- a/Refresher.Core/LogType.cs +++ b/Refresher.Core/LogType.cs @@ -6,7 +6,7 @@ public enum LogType : byte PPU, Verify, CLI, - PatchAccessor, + Accessor, PatchForm, IntegratedPatchForm, InfoRetrieval, @@ -14,6 +14,9 @@ public enum LogType : byte IDPS, OSIntegration, AutoDiscover, + PS3, PSP, + Vita, + RPCS3, Pipeline } \ No newline at end of file diff --git a/Refresher.Core/Patching/EbootPatcher.cs b/Refresher.Core/Patching/EbootPatcher.cs index 6d10205..78d4c2f 100644 --- a/Refresher.Core/Patching/EbootPatcher.cs +++ b/Refresher.Core/Patching/EbootPatcher.cs @@ -436,7 +436,7 @@ public void Patch(string url, bool patchDigest) PPU-{this._ppuHash}: "Refresher Patch ({url})": Games: - "{this.GameName}": + "{this.GameName.ReplaceLineEndings(string.Empty)}": {this.TitleId}: [ {this.GameVersion} ] Author: "Refresher (automated)" Notes: "This patches the game to connect to {url}" diff --git a/Refresher.Core/Patching/EncryptionDetails.cs b/Refresher.Core/Patching/EncryptionDetails.cs new file mode 100644 index 0000000..2ba80ba --- /dev/null +++ b/Refresher.Core/Patching/EncryptionDetails.cs @@ -0,0 +1,7 @@ +namespace Refresher.Core.Patching; + +public class EncryptionDetails +{ + public string? LicenseDirectory { get; internal set; } + public string? DownloadedActDatPath { get; internal set; } +} \ No newline at end of file diff --git a/Refresher.Core/Patching/GameInformation.cs b/Refresher.Core/Patching/GameInformation.cs new file mode 100644 index 0000000..b20818a --- /dev/null +++ b/Refresher.Core/Patching/GameInformation.cs @@ -0,0 +1,18 @@ +namespace Refresher.Core.Patching; + +public class GameInformation +{ + public string TitleId { get; set; } = null!; + + public string? Name { get; set; } + public string? ContentId { get; set; } + public string? Version { get; set; } + + public string? DownloadedEbootPath { get; set; } + public string? DecryptedEbootPath { get; internal set; } + + public override string ToString() + { + return $"[{this.TitleId}] name: {this.Name}, contentId: {this.ContentId}, version: {this.Version}"; + } +} \ No newline at end of file diff --git a/Refresher.Core/Pipelines/CommonStepInputs.cs b/Refresher.Core/Pipelines/CommonStepInputs.cs new file mode 100644 index 0000000..fd17d2a --- /dev/null +++ b/Refresher.Core/Pipelines/CommonStepInputs.cs @@ -0,0 +1,38 @@ +namespace Refresher.Core.Pipelines; + +internal static class CommonStepInputs +{ + internal static readonly StepInput TitleId = new("title-id", "Game", StepInputType.Game) + { + Placeholder = "NPUA80662", + }; + + internal static readonly StepInput ServerUrl = new("url", "Server URL") + { + Placeholder = "https://lbp.littlebigrefresh.com", + }; + + internal static readonly StepInput RPCS3Folder = new("hdd0-path", "RPCS3 dev_hdd0 folder", StepInputType.Directory) + { + // provide an example to Windows users. + // don't bother with other platforms because they should be automatic + Placeholder = @$"C:\Users\{Environment.UserName}\RPCS3\dev_hdd0", + DetermineDefaultValue = DetermineDefaultRpcs3Path, + }; + + // TODO: Cache the last used location for easier entry + private static string? DetermineDefaultRpcs3Path() + { + // RPCS3 builds for Windows are portable, so we can't determine this automatically + if (OperatingSystem.IsWindows()) + return null; + + // ~/.config/rpcs3/dev_hdd0 + string folder = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "rpcs3", "dev_hdd0"); + + if (Directory.Exists(folder)) + return folder; + + return null; + } +} \ No newline at end of file diff --git a/Refresher.Core/Pipelines/Pipeline.cs b/Refresher.Core/Pipelines/Pipeline.cs index 0330472..de27034 100644 --- a/Refresher.Core/Pipelines/Pipeline.cs +++ b/Refresher.Core/Pipelines/Pipeline.cs @@ -1,6 +1,7 @@ using System.Collections.Frozen; using System.Diagnostics; - +using Refresher.Core.Accessors; +using Refresher.Core.Patching; using GlobalState = Refresher.Core.State; namespace Refresher.Core.Pipelines; @@ -10,9 +11,14 @@ public abstract class Pipeline public abstract string Id { get; } public abstract string Name { get; } - public Dictionary Inputs = []; + public readonly Dictionary Inputs = []; public FrozenSet RequiredInputs { get; private set; } + public IPatcher? Patcher { get; internal set; } + public PatchAccessor? Accessor { get; internal set; } + public GameInformation? GameInformation { get; internal set; } + public EncryptionDetails? EncryptionDetails { get; internal set; } + public PipelineState State { get; private set; } = PipelineState.NotStarted; public float Progress @@ -67,6 +73,7 @@ public async Task ExecuteAsync(CancellationToken cancellationToken = default) throw new InvalidOperationException($"Input {input.Id} was not provided to the pipeline before execution."); } + GlobalState.Logger.LogInfo(LogType.Pipeline, $"Pipeline {this.GetType().Name} started."); this.State = PipelineState.Running; byte i = 1; @@ -95,6 +102,7 @@ public async Task ExecuteAsync(CancellationToken cancellationToken = default) i++; } + GlobalState.Logger.LogInfo(LogType.Pipeline, $"Pipeline {this.GetType().Name} finished!"); this.State = PipelineState.Finished; } } \ No newline at end of file diff --git a/Refresher.Core/Pipelines/RPCS3PatchPipeline.cs b/Refresher.Core/Pipelines/RPCS3PatchPipeline.cs new file mode 100644 index 0000000..ccfcf46 --- /dev/null +++ b/Refresher.Core/Pipelines/RPCS3PatchPipeline.cs @@ -0,0 +1,26 @@ +using Refresher.Core.Pipelines.Steps; + +namespace Refresher.Core.Pipelines; + +public class RPCS3PatchPipeline : Pipeline +{ + public override string Id => "rpcs3-patch"; + public override string Name => "RPCS3 Patch"; + protected override List StepTypes => + [ + // Info gathering stage + typeof(SetupEmulatorAccessorStep), + typeof(ValidateGameStep), + typeof(DownloadParamSfoStep), + typeof(DownloadGameEbootStep), + typeof(ReadEbootContentIdStep), + typeof(DownloadGameLicenseStep), + + // Decryption and patch stage + typeof(PrepareSceToolStep), + typeof(DecryptGameEbootStep), + typeof(PrepareEbootPatchCreatorAndVerifyStep), + typeof(ApplyPatchToEbootStep), + // The patch creator will automatically write to the patch file. No upload steps are required. + ]; +} \ No newline at end of file diff --git a/Refresher.Core/Pipelines/Step.cs b/Refresher.Core/Pipelines/Step.cs index a55e0ae..2b3032d 100644 --- a/Refresher.Core/Pipelines/Step.cs +++ b/Refresher.Core/Pipelines/Step.cs @@ -1,3 +1,5 @@ +using Refresher.Core.Patching; + namespace Refresher.Core.Pipelines; public abstract class Step @@ -7,6 +9,9 @@ public abstract class Step public virtual List Inputs { get; } = []; + protected GameInformation Game => this.Pipeline.GameInformation!; + protected EncryptionDetails Encryption => this.Pipeline.EncryptionDetails!; + protected Step(Pipeline pipeline) { this.Pipeline = pipeline; diff --git a/Refresher.Core/Pipelines/StepInput.cs b/Refresher.Core/Pipelines/StepInput.cs index 8ec03ba..1382db1 100644 --- a/Refresher.Core/Pipelines/StepInput.cs +++ b/Refresher.Core/Pipelines/StepInput.cs @@ -4,11 +4,17 @@ public class StepInput { public string Id { get; init; } public string Name { get; init; } + public StepInputType Type { get; init; } - public StepInput(string id, string name) + public string Placeholder { get; init; } + + public Func? DetermineDefaultValue { get; init; } + + public StepInput(string id, string name, StepInputType type = StepInputType.Text) { this.Id = id; this.Name = name; + this.Type = type; } public string GetValueFromPipeline(Pipeline pipeline) diff --git a/Refresher.Core/Pipelines/StepInputType.cs b/Refresher.Core/Pipelines/StepInputType.cs new file mode 100644 index 0000000..7f494ca --- /dev/null +++ b/Refresher.Core/Pipelines/StepInputType.cs @@ -0,0 +1,10 @@ +namespace Refresher.Core.Pipelines; + +public enum StepInputType : byte +{ + Text, + Directory, + OpenFile, + SaveFile, + Game, +} \ No newline at end of file diff --git a/Refresher.Core/Pipelines/Steps/ApplyPatchToEbootStep.cs b/Refresher.Core/Pipelines/Steps/ApplyPatchToEbootStep.cs new file mode 100644 index 0000000..9418c72 --- /dev/null +++ b/Refresher.Core/Pipelines/Steps/ApplyPatchToEbootStep.cs @@ -0,0 +1,20 @@ +namespace Refresher.Core.Pipelines.Steps; + +public class ApplyPatchToEbootStep : Step +{ + public ApplyPatchToEbootStep(Pipeline pipeline) : base(pipeline) + {} + + public override List Inputs => + [ + CommonStepInputs.ServerUrl, + ]; + + public override float Progress { get; protected set; } + public override Task ExecuteAsync(CancellationToken cancellationToken = default) + { + string url = this.Pipeline.Inputs["url"]; + this.Pipeline.Patcher!.Patch(url, true); // TODO: handle autodiscover in pipelines + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/Refresher.Core/Pipelines/Steps/DecryptGameEbootStep.cs b/Refresher.Core/Pipelines/Steps/DecryptGameEbootStep.cs new file mode 100644 index 0000000..1a585b0 --- /dev/null +++ b/Refresher.Core/Pipelines/Steps/DecryptGameEbootStep.cs @@ -0,0 +1,24 @@ +using SCEToolSharp; + +namespace Refresher.Core.Pipelines.Steps; + +public class DecryptGameEbootStep : Step +{ + public DecryptGameEbootStep(Pipeline pipeline) : base(pipeline) + {} + + public override float Progress { get; protected set; } + public override Task ExecuteAsync(CancellationToken cancellationToken = default) + { + string tempFile = this.Game.DecryptedEbootPath = Path.GetTempFileName(); + LibSceToolSharp.Decrypt(this.Game.DownloadedEbootPath!, tempFile); + + // HACK: scetool doesn't give us result codes, check if the file has been written to instead + if (new FileInfo(tempFile).Length == 0) + { + throw new Exception("Decryption failed."); + } + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/Refresher.Core/Pipelines/Steps/DownloadGameEbootStep.cs b/Refresher.Core/Pipelines/Steps/DownloadGameEbootStep.cs new file mode 100644 index 0000000..4f3e05a --- /dev/null +++ b/Refresher.Core/Pipelines/Steps/DownloadGameEbootStep.cs @@ -0,0 +1,52 @@ +using Refresher.Core.Accessors; + +namespace Refresher.Core.Pipelines.Steps; + +public class DownloadGameEbootStep : Step +{ + public DownloadGameEbootStep(Pipeline pipeline) : base(pipeline) + {} + + public override float Progress { get; protected set; } + public override Task ExecuteAsync(CancellationToken cancellationToken = default) + { + string titleId = this.Game.TitleId; + string usrDir = $"game/{titleId}/USRDIR"; + + string ebootPath = Path.Combine(usrDir, "EBOOT.BIN.ORIG"); // Prefer original backup over active copy + PatchAccessor.Try(() => + { + if (this.Pipeline.Accessor!.FileExists(ebootPath)) return; + // If the backup doesn't exist, use the EBOOT.BIN + + State.Logger.LogInfo(Accessor, "Couldn't find an original backup of the EBOOT, using active copy. This is not an error."); + ebootPath = Path.Combine(usrDir, "EBOOT.BIN"); + + this.Progress = 0.25f; + + if (this.Pipeline.Accessor.FileExists(ebootPath)) return; + + // If we land here, then we have no valid patch target without any way to recover. + // This is very inconvenient for us and the user. + throw new FileNotFoundException("The EBOOT.BIN file does not exist, nor does the original backup exist." + + "This usually means you haven't installed any updates for your game."); + }); + + this.Progress = 0.5f; + + string downloadedFile = null!; + PatchAccessor.Try(() => + { + downloadedFile = this.Pipeline.Accessor!.DownloadFile(ebootPath); + this.Game.DownloadedEbootPath = downloadedFile; + }); + + State.Logger.LogDebug(Accessor, $"Downloaded EBOOT Path: {downloadedFile}"); + if (!File.Exists(downloadedFile)) + { + throw new FileNotFoundException("Could not find the EBOOT we downloaded. This is likely a bug. Patching cannot continue."); + } + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/Refresher.Core/Pipelines/Steps/DownloadGameLicenseStep.cs b/Refresher.Core/Pipelines/Steps/DownloadGameLicenseStep.cs new file mode 100644 index 0000000..a5d54d4 --- /dev/null +++ b/Refresher.Core/Pipelines/Steps/DownloadGameLicenseStep.cs @@ -0,0 +1,75 @@ +using Refresher.Core.Patching; +using SCEToolSharp; + +namespace Refresher.Core.Pipelines.Steps; + +public class DownloadGameLicenseStep : Step +{ + public DownloadGameLicenseStep(Pipeline pipeline) : base(pipeline) + {} + + public override float Progress { get; protected set; } + public override Task ExecuteAsync(CancellationToken cancellationToken = default) + { + GameInformation game = this.Game; + string contentId = game.ContentId!; + + string licenseDir = Path.Join(Path.GetTempPath(), "refresher-" + Random.Shared.Next()); + Directory.CreateDirectory(licenseDir); + + this.Pipeline.EncryptionDetails = new EncryptionDetails() + { + LicenseDirectory = licenseDir, + }; + + bool found = false; + foreach (string user in this.Pipeline.Accessor!.GetDirectoriesInDirectory(Path.Combine("home"))) + { + State.Logger.LogDebug(Crypto, $"Checking all license files in {user}"); + string exdataFolder = Path.Combine(user, "exdata"); + + if (!this.Pipeline.Accessor.DirectoryExists(exdataFolder)) + { + State.Logger.LogDebug(Crypto, $"Exdata folder doesn't exist for user {user}, skipping..."); + continue; + } + + foreach (string licenseFile in this.Pipeline.Accessor.GetFilesInDirectory(exdataFolder)) + { + //If the license file does not contain the content ID in its path, skip it + if (!licenseFile.Contains(contentId) && !licenseFile.Contains(game.TitleId)) + continue; + + State.Logger.LogDebug(Crypto, $"Found compatible rap: {licenseFile}"); + + string actDatPath = Path.Combine(user, "exdata", "act.dat"); + + //If it is a valid content id, lets download that user's act.dat, if its there + if (!found && this.Pipeline.Accessor.FileExists(actDatPath)) + { + string downloadedActDat = this.Pipeline.Accessor.DownloadFile(actDatPath); + this.Encryption.DownloadedActDatPath = downloadedActDat; + } + + //And the license file + string downloadedLicenseFile = this.Pipeline.Accessor.DownloadFile(licenseFile); + File.Move(downloadedLicenseFile, Path.Join(licenseDir, Path.GetFileName(licenseFile))); + + State.Logger.LogInfo(Crypto, $"Downloaded compatible license file {licenseFile}."); + + found = true; + } + + if (found) + break; + } + + if (!found) + { + State.Logger.LogWarning(Crypto, "Couldn't find a license file for {0}. For disc copies, this is normal." + + "For digital copies, this may present problems. Attempting to continue without it...", game.TitleId); + } + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/Refresher.Core/Pipelines/Steps/DownloadParamSfoStep.cs b/Refresher.Core/Pipelines/Steps/DownloadParamSfoStep.cs new file mode 100644 index 0000000..0420cf4 --- /dev/null +++ b/Refresher.Core/Pipelines/Steps/DownloadParamSfoStep.cs @@ -0,0 +1,80 @@ +using Refresher.Core.Accessors; +using Refresher.Core.Patching; +using Refresher.Core.Verification; + +namespace Refresher.Core.Pipelines.Steps; + +public class DownloadParamSfoStep : Step +{ + public DownloadParamSfoStep(Pipeline pipeline) : base(pipeline) + {} + + public override float Progress { get; protected set; } + public override Task ExecuteAsync(CancellationToken cancellationToken = default) + { + GameInformation game = this.Game; + string gamePath = $"game/{game.TitleId}"; + + Stream? sfoStream = null; + PatchAccessor.Try(() => + { + string sfoLocation = $"{gamePath}/PARAM.SFO"; + + if(this.Pipeline.Accessor!.FileExists(sfoLocation)) + sfoStream = this.Pipeline.Accessor.OpenRead(sfoLocation); + }); + + ParamSfo? sfo = null; + try + { + if (sfoStream == null) + { + throw new FileNotFoundException("The PARAM.SFO file does not exist. This usually means you haven't installed any updates for your game."); + } + this.ParseSfoStream(sfoStream, out sfo); + } + catch (EndOfStreamException) + { + State.Logger.LogError(InfoRetrieval, $"Couldn't load {game}'s PARAM.SFO because the file was incomplete."); + } + catch(Exception e) + { + game.Name = $"Unknown PARAM.SFO [{game}]"; + + State.Logger.LogError(InfoRetrieval, $"Couldn't load {game}'s PARAM.SFO: {e}"); + if (sfo != null) + { + State.Logger.LogDebug(InfoRetrieval, $"PARAM.SFO version:{sfo.Version} dump:"); + foreach ((string? key, object? value) in sfo.Table) + { + State.Logger.LogDebug(InfoRetrieval, $" '{key}' = '{value}'"); + } + } + else + { + State.Logger.LogWarning(InfoRetrieval, "PARAM.SFO was not read, can't dump"); + } + + SentrySdk.CaptureException(e); + } + + State.Logger.LogInfo(InfoRetrieval, "Parsed PARAM.SFO: " + game); + return Task.CompletedTask; + } + + private void ParseSfoStream(Stream sfoStream, out ParamSfo sfo) + { + sfo = new ParamSfo(sfoStream); + GameInformation info = this.Game; + + info.Version = "01.00"; + if (sfo.Table.TryGetValue("APP_VER", out object? value)) + { + string? appVersion = value.ToString(); + if (appVersion != null) + info.Version = appVersion; + } + + info.Name = sfo.Table["TITLE"].ToString(); + } +} \ No newline at end of file diff --git a/Refresher.Core/Pipelines/Steps/PrepareEbootPatchCreatorAndVerifyStep.cs b/Refresher.Core/Pipelines/Steps/PrepareEbootPatchCreatorAndVerifyStep.cs new file mode 100644 index 0000000..97d2572 --- /dev/null +++ b/Refresher.Core/Pipelines/Steps/PrepareEbootPatchCreatorAndVerifyStep.cs @@ -0,0 +1,46 @@ +using Refresher.Core.Patching; +using Refresher.Core.Verification; + +namespace Refresher.Core.Pipelines.Steps; + +public class PrepareEbootPatchCreatorAndVerifyStep : Step +{ + public PrepareEbootPatchCreatorAndVerifyStep(Pipeline pipeline) : base(pipeline) + {} + + public override List Inputs => + [ + CommonStepInputs.RPCS3Folder, + CommonStepInputs.ServerUrl, + ]; + + public override float Progress { get; protected set; } + public override Task ExecuteAsync(CancellationToken cancellationToken = default) + { + string url = this.Pipeline.Inputs["url"]; + + EbootPatcher patcher = new(File.Open(this.Game.DecryptedEbootPath!, FileMode.Open, FileAccess.ReadWrite)); + patcher.GenerateRpcs3Patch = true; + patcher.Rpcs3PatchFolder = Path.GetFullPath(Path.Combine(this.Pipeline.Inputs["hdd0-path"], "..", "patches")); + patcher.TitleId = this.Game.TitleId; + patcher.GameName = this.Game.Name; + patcher.GameVersion = this.Game.Version; + + State.Logger.LogDebug(RPCS3, $"RPCS3 patches folder: {patcher.Rpcs3PatchFolder}"); + + this.Pipeline.Patcher = patcher; + + List messages = patcher.Verify(url, true); // TODO: handle autodiscover in pipelines + foreach (Message message in messages) + { + State.Logger.LogInfo(Patcher, message.ToString()); + } + + if (messages.Any(m => m.Level == MessageLevel.Error)) + { + throw new Exception("There were errors while verifying the patch details against the EBOOT. Check the log for more information."); + } + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/Refresher.Core/Pipelines/Steps/PrepareEbootPatcherAndVerifyStep.cs b/Refresher.Core/Pipelines/Steps/PrepareEbootPatcherAndVerifyStep.cs new file mode 100644 index 0000000..e7d7f6f --- /dev/null +++ b/Refresher.Core/Pipelines/Steps/PrepareEbootPatcherAndVerifyStep.cs @@ -0,0 +1,38 @@ +using Refresher.Core.Patching; +using Refresher.Core.Verification; + +namespace Refresher.Core.Pipelines.Steps; + +public class PrepareEbootPatcherAndVerifyStep : Step +{ + public PrepareEbootPatcherAndVerifyStep(Pipeline pipeline) : base(pipeline) + {} + + public override List Inputs => + [ + CommonStepInputs.ServerUrl, + ]; + + public override float Progress { get; protected set; } + public override Task ExecuteAsync(CancellationToken cancellationToken = default) + { + string url = this.Pipeline.Inputs["url"]; + + EbootPatcher patcher = new(File.Open(this.Game.DecryptedEbootPath!, FileMode.Open, FileAccess.ReadWrite)); + + this.Pipeline.Patcher = patcher; + + List messages = patcher.Verify(url, true); // TODO: handle autodiscover in pipelines + foreach (Message message in messages) + { + State.Logger.LogInfo(Patcher, message.ToString()); + } + + if (messages.Any(m => m.Level == MessageLevel.Error)) + { + throw new Exception("There were errors while verifying the patch details against the EBOOT. Check the log for more information."); + } + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/Refresher.Core/Pipelines/Steps/PrepareSceToolStep.cs b/Refresher.Core/Pipelines/Steps/PrepareSceToolStep.cs new file mode 100644 index 0000000..e63707a --- /dev/null +++ b/Refresher.Core/Pipelines/Steps/PrepareSceToolStep.cs @@ -0,0 +1,24 @@ +using static SCEToolSharp.LibSceToolSharp; + +namespace Refresher.Core.Pipelines.Steps; + +public class PrepareSceToolStep : Step +{ + public PrepareSceToolStep(Pipeline pipeline) : base(pipeline) + { + } + + public override float Progress { get; protected set; } + public override Task ExecuteAsync(CancellationToken cancellationToken = default) + { + Init(); + + SetRapDirectory(this.Encryption.LicenseDirectory!); + SetRifPath(this.Encryption.LicenseDirectory!); + + if(this.Encryption.DownloadedActDatPath != null) + SetActDatFilePath(this.Encryption.DownloadedActDatPath); + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/Refresher.Core/Pipelines/Steps/ReadEbootContentIdStep.cs b/Refresher.Core/Pipelines/Steps/ReadEbootContentIdStep.cs new file mode 100644 index 0000000..2777cdf --- /dev/null +++ b/Refresher.Core/Pipelines/Steps/ReadEbootContentIdStep.cs @@ -0,0 +1,27 @@ +using SCEToolSharp; + +namespace Refresher.Core.Pipelines.Steps; + +public class ReadEbootContentIdStep : Step +{ + public ReadEbootContentIdStep(Pipeline pipeline) : base(pipeline) + {} + + public override float Progress { get; protected set; } + public override Task ExecuteAsync(CancellationToken cancellationToken = default) + { + string ebootPath = this.Game.DownloadedEbootPath!; + + LibSceToolSharp.Init(); + this.Progress = 0.5f; + + string? contentId = LibSceToolSharp.GetContentId(ebootPath)?.TrimEnd('\0'); + if (contentId == null) + throw new Exception("Unable to retrieve the content ID from the game's EBOOT."); + this.Progress = 1f; + + this.Game.ContentId = contentId; + State.Logger.LogDebug(InfoRetrieval, "Got content ID from the game's EBOOT: {0}", contentId); + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/Refresher.Core/Pipelines/Steps/SetupEmulatorAccessorStep.cs b/Refresher.Core/Pipelines/Steps/SetupEmulatorAccessorStep.cs new file mode 100644 index 0000000..dce914f --- /dev/null +++ b/Refresher.Core/Pipelines/Steps/SetupEmulatorAccessorStep.cs @@ -0,0 +1,24 @@ +using Refresher.Core.Accessors; + +namespace Refresher.Core.Pipelines.Steps; + +public class SetupEmulatorAccessorStep : Step +{ + public SetupEmulatorAccessorStep(Pipeline pipeline) : base(pipeline) + {} + + public override float Progress { get; protected set; } + + public override List Inputs { get; } = [ + CommonStepInputs.RPCS3Folder, + ]; + + public override Task ExecuteAsync(CancellationToken cancellationToken = default) + { + string path = this.Inputs[0].GetValueFromPipeline(this.Pipeline); + State.Logger.LogDebug(RPCS3, $"Using RPCS3 path {path}"); + this.Pipeline.Accessor = new EmulatorPatchAccessor(path); + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/Refresher.Core/Pipelines/Steps/ValidateGameStep.cs b/Refresher.Core/Pipelines/Steps/ValidateGameStep.cs new file mode 100644 index 0000000..a025801 --- /dev/null +++ b/Refresher.Core/Pipelines/Steps/ValidateGameStep.cs @@ -0,0 +1,36 @@ +using Refresher.Core.Patching; + +namespace Refresher.Core.Pipelines.Steps; + +public class ValidateGameStep : Step +{ + public ValidateGameStep(Pipeline pipeline) : base(pipeline) + {} + + public override float Progress { get; protected set; } + + public override List Inputs { get; } = + [ + CommonStepInputs.TitleId, + ]; + + public override Task ExecuteAsync(CancellationToken cancellationToken = default) + { + string titleId = CommonStepInputs.TitleId.GetValueFromPipeline(this.Pipeline).Trim(); + string gamePath = $"game/{titleId}"; + + // sanity check. UI will not allow this to happen, but CLI will + if (titleId.Length != "NPUA80662".Length) + throw new InvalidOperationException("Title ID does not match expected length. Did you type the ID in correctly?"); + + if(!this.Pipeline.Accessor!.DirectoryExists(gamePath)) + throw new FileNotFoundException("The game directory does not exist. This usually means you haven't installed any updates for your game."); + + this.Pipeline.GameInformation = new GameInformation + { + TitleId = titleId, + }; + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/Refresher/Extensions/ControlExtensions.cs b/Refresher/Extensions/ControlExtensions.cs new file mode 100644 index 0000000..8beff6c --- /dev/null +++ b/Refresher/Extensions/ControlExtensions.cs @@ -0,0 +1,16 @@ +using Eto.Forms; + +namespace Refresher.Core.Extensions; + +public static class ControlExtensions +{ + public static string GetUserInput(this Control control) + { + return control switch + { + TextControl textControl => textControl.Text, + FilePicker filePicker => filePicker.FilePath, + _ => throw new ArgumentOutOfRangeException(control.GetType().Name), + }; + } +} \ No newline at end of file diff --git a/Refresher/UI/ConsolePatchForm.cs b/Refresher/UI/ConsolePatchForm.cs index b1787ac..413fb1b 100644 --- a/Refresher/UI/ConsolePatchForm.cs +++ b/Refresher/UI/ConsolePatchForm.cs @@ -55,7 +55,7 @@ protected override void RevertToOriginalExecutable(object? sender, EventArgs e) private bool InitializePatchAccessor() { this.DisposePatchAccessor(); - State.Logger.LogTrace(LogType.PatchAccessor, "Making a new patch accessor"); + State.Logger.LogTrace(LogType.Accessor, "Making a new patch accessor"); try { this.Accessor = new ConsolePatchAccessor(this._remoteAddress.Text.Trim()); @@ -86,7 +86,7 @@ private bool InitializePatchAccessor() private void DisposePatchAccessor() { - State.Logger.LogTrace(LogType.PatchAccessor, "Disposing patch accessor"); + State.Logger.LogTrace(LogType.Accessor, "Disposing patch accessor"); if (this.Accessor is IDisposable disposable) disposable.Dispose(); } diff --git a/Refresher/UI/MainForm.cs b/Refresher/UI/MainForm.cs index 0d4c00d..95ec17a 100644 --- a/Refresher/UI/MainForm.cs +++ b/Refresher/UI/MainForm.cs @@ -13,16 +13,18 @@ public class MainForm : RefresherForm { StackLayout layout; this.Content = layout = new StackLayout - ( + // ReSharper disable once RedundantExplicitParamsArrayCreation + ([ new Label { Text = "Welcome to Refresher! Please pick a patching method to continue." }, new Button((_, _) => this.ShowChild()) { Text = "File Patch (using a .ELF)" }, new Button((_, _) => this.ShowChild()) { Text = "RPCS3 Patch" }, new Button((_, _) => this.ShowChild()) { Text = "PS3 Patch" }, - new Button((_, _) => this.ShowChild()) { Text = "PSP Setup" } + new Button((_, _) => this.ShowChild()) { Text = "PSP Setup" }, #if DEBUG - ,this.PipelineButton() + this.PipelineButton(), #endif - ); + this.PipelineButton(), + ]); layout.Spacing = 5; layout.HorizontalContentAlignment = HorizontalAlignment.Stretch; diff --git a/Refresher/UI/PipelineForm.cs b/Refresher/UI/PipelineForm.cs index 757f6c6..469e3a2 100644 --- a/Refresher/UI/PipelineForm.cs +++ b/Refresher/UI/PipelineForm.cs @@ -1,6 +1,8 @@ +using Eto; using Eto.Drawing; using Eto.Forms; using Refresher.Core; +using Refresher.Core.Extensions; using Refresher.Core.Logging; using Refresher.Core.Pipelines; @@ -77,7 +79,21 @@ private void InitializePipeline() this._formLayout.Rows.Clear(); foreach (StepInput input in this._pipeline.RequiredInputs) { - this._formLayout.Rows.Add(AddField(input)); + TableRow row; + switch (input.Type) + { + case StepInputType.Game: + case StepInputType.Text: + row = AddField(input); + break; + case StepInputType.Directory: + row = AddField(input); + break; + default: + throw new ArgumentOutOfRangeException(); + } + + this._formLayout.Rows.Add(row); } this.UpdateSubtitle(this._pipeline.Name); @@ -117,7 +133,7 @@ private void OnButtonClick(object? sender, EventArgs e) foreach (TableRow row in this._formLayout.Rows) { string id = row.Cells[0].Control.ToolTip; - string value = ((TextControl)row.Cells[1].Control).Text; + string value = row.Cells[1].Control.GetUserInput(); this._pipeline.Inputs.Add(id, value); } @@ -130,7 +146,7 @@ private void OnButtonClick(object? sender, EventArgs e) } catch (Exception ex) { - State.Logger.LogError(LogType.Pipeline, $"Error while running pipeline {this._pipeline.Name}: {ex.Message}"); + State.Logger.LogError(LogType.Pipeline, $"Error while running pipeline {this._pipeline.Name}: {ex}"); } }, this._cts?.Token ?? default); @@ -147,6 +163,29 @@ private void OnButtonClick(object? sender, EventArgs e) }; Control control = new TControl(); + TextBox? textBox = control as TextBox; + + string? newValue = input.DetermineDefaultValue?.Invoke(); + + if (textBox != null) + { + textBox.Text = newValue; + textBox.PlaceholderText = input.Placeholder; + } + else if (control is FilePicker filePicker) + { + filePicker.FilePath = newValue; + + // ReSharper disable once SwitchExpressionHandlesSomeKnownEnumValuesWithExceptionInDefault + filePicker.FileAction = input.Type switch + { + StepInputType.Directory => FileAction.SelectFolder, + StepInputType.OpenFile => FileAction.OpenFile, + StepInputType.SaveFile => FileAction.SaveFile, + _ => throw new ArgumentOutOfRangeException(), + }; + } + return new TableRow(label, control); }