Skip to content

Commit

Permalink
Merge pull request #129 from Kylemc1413/feature/error-messages
Browse files Browse the repository at this point in the history
Show error and catch exceptions when failing to load beatmap
  • Loading branch information
Meivyn authored Jan 21, 2024
2 parents e4771cd + 3ea4dd7 commit 7745b93
Show file tree
Hide file tree
Showing 5 changed files with 180 additions and 15 deletions.
32 changes: 32 additions & 0 deletions source/SongCore/HarmonyPatches/HarmonyTranspilersFixPatch.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using System.Collections.Generic;
using System.Reflection;
using System.Reflection.Emit;
using HarmonyLib;
using SongCore.Utilities;

namespace SongCore.HarmonyPatches
{
/// <summary>
/// This patch fixes an <a href="https://github.com/BepInEx/HarmonyX/issues/65">issue</a> with HarmonyX that causes it
/// to remove certain instructions when patching a method with a transpiler. It removes the condition in
/// <see cref="HarmonyLib.Internal.Patching.ILManipulator.WriteTo" /> that deletes existing <c>Leave</c>, <c>Endfinally</c>
/// and <c>Endfilter</c> instructions from the patched method when they are followed by an exception block.
/// </summary>
// TODO: Remove this once fixed.
internal class HarmonyTranspilersFixPatch
{
public static MethodBase TargetMethod() => AccessTools.Method("HarmonyLib.Internal.Patching.ILManipulator:WriteTo");

public static IEnumerable<CodeInstruction> Transpiler(IEnumerable<CodeInstruction> instructions)
{
return new CodeMatcher(instructions)
.MatchStartForward(
new CodeMatch(OpCodes.Ldloc_3),
new CodeMatch(OpCodes.Ldfld),
new CodeMatch(i => i.opcode == OpCodes.Ldsfld && ((FieldInfo)i.operand).Name == nameof(OpCodes.Leave)))
.ThrowIfInvalid()
.RemoveInstructionsInRange(76, 112)
.InstructionEnumeration();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Reflection.Emit;
using HarmonyLib;
using MonoMod.Utils;
using Polyglot;
using SongCore.Utilities;

namespace SongCore.HarmonyPatches
{
/// <summary>
/// This patch catches all exceptions and displays an error message to the user
/// in the <see cref="StandardLevelDetailView"/> when the game is loading beatmap levels.
/// </summary>
// TODO: Make this use MethodType.Async once supported.
[HarmonyPatch]
internal class StandardLevelDetailViewControllerPatch
{
private static MethodBase TargetMethod() => AccessTools.Method(typeof(StandardLevelDetailViewController), nameof(StandardLevelDetailViewController.ShowLoadingAndDoSomething)).GetStateMachineTarget();

private static IEnumerable<CodeInstruction> Transpiler(IEnumerable<CodeInstruction> instructions)
{
var codeMatcher = new CodeMatcher(instructions)
.MatchStartForward(new CodeMatch(i => i.blocks.FirstOrDefault()?.blockType == ExceptionBlockType.BeginCatchBlock))
.ThrowIfInvalid();
codeMatcher.Instruction.blocks[0].catchType = typeof(Exception);
return codeMatcher
.SetOpcodeAndAdvance(OpCodes.Stloc_3)
.Insert(
new CodeInstruction(OpCodes.Ldloc_1),
new CodeInstruction(OpCodes.Ldloc_3),
Transpilers.EmitDelegate<Action<StandardLevelDetailViewController, Exception>>((standardLevelDetailViewController, ex) =>
{
var handled = false;
switch (ex)
{
case OperationCanceledException:
// Base game skips those.
return;
case ArgumentOutOfRangeException:
{
if (ex.StackTrace.Contains(nameof(BeatmapCharacteristicSegmentedControlController)))
{
const string errorText = "Error loading beatmap. Missing or unknown characteristic.";
standardLevelDetailViewController.ShowContent(StandardLevelDetailViewController.ContentType.Error, errorText);
Logging.Logger.Error(errorText);
handled = true;
}

break;
}
case ArgumentNullException:
{
if (ex.StackTrace.Contains(nameof(BeatmapSaveDataHelpers.GetVersion)))
{
const string errorText = "Error loading beatmap version.";
standardLevelDetailViewController.ShowContent(StandardLevelDetailViewController.ContentType.Error, errorText);
Logging.Logger.Error(errorText);
handled = true;
}

break;
}
}

if (!handled)
{
standardLevelDetailViewController.ShowContent(StandardLevelDetailViewController.ContentType.Error, Localization.Get(StandardLevelDetailViewController.kLoadingDataErrorLocalizationKey));
}

Logging.Logger.Error(ex);
}))
.InstructionEnumeration();
}
}
}
11 changes: 7 additions & 4 deletions source/SongCore/Plugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using IPA.Config;
using IPA.Config.Stores;
using IPA.Loader;
using SongCore.HarmonyPatches;
using UnityEngine;
using IPALogger = IPA.Logging.Logger;

Expand All @@ -17,8 +18,8 @@ namespace SongCore
[Plugin(RuntimeOptions.SingleStartInit)]
public class Plugin
{
private static PluginMetadata _metadata;
private static Harmony? _harmony;
private readonly PluginMetadata _metadata;
private readonly Harmony _harmony;

internal static SConfiguration Configuration { get; private set; }

Expand All @@ -29,22 +30,24 @@ public class Plugin
public static string noArrowsCharacteristicName = "NoArrows";

[Init]
public void Init(IPALogger pluginLogger, PluginMetadata metadata)
public Plugin(IPALogger pluginLogger, PluginMetadata metadata)
{
// Workaround for creating BSIPA config in Userdata subdir
Directory.CreateDirectory(Path.Combine(UnityGame.UserDataPath, nameof(SongCore)));
Configuration = Config.GetConfigFor(nameof(SongCore) + Path.DirectorySeparatorChar + nameof(SongCore)).Generated<SConfiguration>();

Logging.Logger = pluginLogger;
_metadata = metadata;
_harmony = new Harmony("com.kyle1413.BeatSaber.SongCore");
}

[OnStart]
public void OnApplicationStart()
{
BSMLSettings.instance.AddSettingsMenu(nameof(SongCore), "SongCore.UI.settings.bsml", new SCSettingsController());

_harmony = Harmony.CreateAndPatchAll(_metadata.Assembly, "com.kyle1413.BeatSaber.SongCore");
_harmony.Patch(HarmonyTranspilersFixPatch.TargetMethod(), null, null, new HarmonyMethod(AccessTools.Method(typeof(HarmonyTranspilersFixPatch), nameof(HarmonyTranspilersFixPatch.Transpiler))));
_harmony.PatchAll(_metadata.Assembly);

BasicUI.GetIcons();
BS_Utils.Utilities.BSEvents.levelSelected += BSEvents_levelSelected;
Expand Down
26 changes: 15 additions & 11 deletions source/SongCore/SongCore.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<PropertyGroup>
<TargetFramework>net472</TargetFramework>
<OutputType>Library</OutputType>
<LangVersion>8</LangVersion>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<LocalRefsDir Condition="Exists('..\Refs')">..\Refs</LocalRefsDir>
Expand All @@ -24,6 +24,10 @@
<HintPath>$(BeatSaberDir)\Libs\0Harmony.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="BeatmapCore">
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\BeatmapCore.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="BGLib.AppFlow">
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\BGLib.AppFlow.dll</HintPath>
<Private>false</Private>
Expand All @@ -32,16 +36,12 @@
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\BGLib.UnityExtension.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="BSML">
<HintPath>$(BeatSaberDir)\Plugins\BSML.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="BS_Utils">
<HintPath>$(BeatSaberDir)\Plugins\BS_Utils.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="BeatmapCore">
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\BeatmapCore.dll</HintPath>
<Reference Include="BSML">
<HintPath>$(BeatSaberDir)\Plugins\BSML.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="Colors">
Expand Down Expand Up @@ -71,10 +71,18 @@
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\Menu.ColorSettings.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="MonoMod.Utils">
<HintPath>$(BeatSaberDir)\Libs\MonoMod.Utils.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Newtonsoft.Json">
<HintPath>$(BeatSaberDir)\Libs\Newtonsoft.Json.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="Polyglot">
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\Polyglot.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Tweening">
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\Tweening.dll</HintPath>
<Private>False</Private>
Expand Down Expand Up @@ -110,10 +118,6 @@
<Reference Include="Zenject-usage">
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\Zenject-usage.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Polyglot">
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\Polyglot.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>

Expand Down
48 changes: 48 additions & 0 deletions source/SongCore/Utilities/CodeMatcherExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using System;
using HarmonyLib;
using IPA.Utilities;

namespace SongCore.Utilities
{
public static class CodeMatcherExtensions
{
private static readonly FieldAccessor<CodeMatcher, string>.Accessor LastErrorAccessor =
FieldAccessor<CodeMatcher, string>.GetAccessor("lastError");

/// <summary>Prints the list of instructions of this code matcher instance.</summary>
/// <param name="codeMatcher">The code matcher instance.</param>
/// <returns>The code matcher instance.</returns>
public static CodeMatcher PrintInstructions(this CodeMatcher codeMatcher)
{
var instructions = codeMatcher.Instructions();
for (var i = 0; i < instructions.Count; i++)
{
Logging.Logger.Info($"\t {i} {instructions[i]}");
}

return codeMatcher;
}

/// <summary>Throws an exception if current state is invalid (position out of bounds/last match failed).</summary>
/// <param name="codeMatcher">The code matcher instance.</param>
/// <param name="explanation">Optional explanation of where/why the exception was thrown that will be added to the exception message.</param>
/// <exception cref="InvalidOperationException">Current state is invalid.</exception>
/// <returns>The code matcher instance.</returns>
public static CodeMatcher ThrowIfInvalid(this CodeMatcher codeMatcher, string? explanation = null)
{
if (codeMatcher.IsInvalid)
{
var lastError = LastErrorAccessor(ref codeMatcher);
var errMsg = lastError;
if (!string.IsNullOrWhiteSpace(explanation))
{
errMsg = $"{explanation} - Current state is invalid. Details: {lastError}";
}

throw new InvalidOperationException(errMsg);
}

return codeMatcher;
}
}
}

0 comments on commit 7745b93

Please sign in to comment.