Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
558 changes: 558 additions & 0 deletions ModSettingsBaseLibToRitsuGeneratedReflectionMirror.cs

Large diffs are not rendered by default.

1,506 changes: 1,506 additions & 0 deletions RitsuModSettingsSubmenu.cs

Large diffs are not rendered by default.

527 changes: 527 additions & 0 deletions STS2-RitsuLib-main/Content/Patches/ModelDbContentPatches.cs

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
using System.Buffers.Binary;
using System.Collections;
using System.IO.Hashing;
using System.Reflection;
using System.Text;
using Godot;
using HarmonyLib;
using MegaCrit.Sts2.Core.Models;
using MegaCrit.Sts2.Core.Multiplayer.Serialization;
using MegaCrit.Sts2.Core.Timeline;
using STS2RitsuLib.Patching.Core;
using STS2RitsuLib.Patching.Models;

namespace STS2RitsuLib.Content.Patches
{
/// <summary>
/// <see cref="ModelIdSerializationCache.Init" /> only walks <see cref="ModelDb.AllAbstractModelSubtypes" />, so
/// Reflection.Emit placeholder models (and any other injected types not returned by mod subtype scan) never receive
/// net
/// IDs. This postfix merges <see cref="ModelDb" /> content and recomputes bit sizes and hash like vanilla
/// <c>Init</c>.
/// </summary>
public class ModelIdSerializationCacheDynamicContentPatch : IPatchMethod
{
/// <inheritdoc />
public static string PatchId => "model_id_serialization_cache_dynamic_content";

/// <inheritdoc />
public static string Description =>
"Include ModelDb-injected dynamic mod models in ModelIdSerializationCache maps and hash";

/// <inheritdoc />
public static bool IsCritical => true;

/// <inheritdoc />
public static ModPatchTarget[] GetTargets()
{
return [new(typeof(ModelIdSerializationCache), nameof(ModelIdSerializationCache.Init))];
}

/// <summary>
/// After vanilla <see cref="ModelIdSerializationCache.Init" />, merges injected <see cref="ModelDb" /> entries
/// into net ID maps and refreshes bit sizes and hash.
/// </summary>
public static void Postfix()
{
var contentById = GetModelDbContentById();
if (contentById == null || contentById.Count == 0)
return;

var catMap =
GetStaticField<Dictionary<string, int>>(typeof(ModelIdSerializationCache), "_categoryNameToNetIdMap");
var catList = GetStaticField<List<string>>(typeof(ModelIdSerializationCache), "_netIdToCategoryNameMap");
var entMap =
GetStaticField<Dictionary<string, int>>(typeof(ModelIdSerializationCache), "_entryNameToNetIdMap");
var entList = GetStaticField<List<string>>(typeof(ModelIdSerializationCache), "_netIdToEntryNameMap");

if (catMap == null || catList == null || entMap == null || entList == null)
return;

var addedCategoryCount = 0;
var addedEntryCount = 0;
foreach (DictionaryEntry entry in contentById)
{
if (entry.Key is not ModelId id)
continue;

if (EnsureCategory(id.Category, catMap, catList))
addedCategoryCount++;

if (EnsureEntry(id.Entry, entMap, entList))
addedEntryCount++;
}

var maxCategory = catList.Count;
var maxEntry = entList.Count;
var epochList = GetStaticField<List<string>>(typeof(ModelIdSerializationCache), "_netIdToEpochNameMap");
var maxEpoch = epochList?.Count ?? 0;

SetStaticProperty(typeof(ModelIdSerializationCache), nameof(ModelIdSerializationCache.CategoryIdBitSize),
Mathf.CeilToInt(Math.Log2(maxCategory)));
SetStaticProperty(typeof(ModelIdSerializationCache), nameof(ModelIdSerializationCache.EntryIdBitSize),
Mathf.CeilToInt(Math.Log2(maxEntry)));
SetStaticProperty(typeof(ModelIdSerializationCache), nameof(ModelIdSerializationCache.EpochIdBitSize),
Mathf.CeilToInt(Math.Log2(maxEpoch)));

var newHash = ComputeHashLikeVanilla(contentById, maxCategory, maxEntry, maxEpoch);
SetStaticProperty(typeof(ModelIdSerializationCache), nameof(ModelIdSerializationCache.Hash), newHash);

if (addedCategoryCount > 0 || addedEntryCount > 0)
{
PatchLog.For<ModelIdSerializationCacheDynamicContentPatch>().Info(
$"[AndroidCompat] ModelIdSerializationCache synced dynamic content: " +
$"{addedCategoryCount} category ID(s), {addedEntryCount} entry ID(s), hash=0x{newHash:X8}");
}
}

private static IDictionary? GetModelDbContentById()
{
var field = AccessTools.DeclaredField(typeof(ModelDb), "_contentById");
return field?.GetValue(null) as IDictionary;
}

private static uint ComputeHashLikeVanilla(IDictionary contentById, int maxCategory, int maxEntry, int maxEpoch)
{
var buffer = new byte[512];
var xxHash = new XxHash32();

var types = new HashSet<Type>();
foreach (var t in ModelDb.AllAbstractModelSubtypes)
types.Add(t);

foreach (DictionaryEntry entry in contentById)
if (entry.Value is AbstractModel model)
types.Add(model.GetType());

var sorted = types.ToList();
sorted.Sort((a, b) => string.CompareOrdinal(a.Name, b.Name));

foreach (var id in sorted.Select(ModelDb.GetId))
{
AppendUtf8(xxHash, id.Category, buffer);
AppendUtf8(xxHash, id.Entry, buffer);
}

foreach (var epochId in EpochModel.AllEpochIds)
AppendUtf8(xxHash, epochId, buffer);

BinaryPrimitives.WriteInt32LittleEndian(buffer.AsSpan(), maxCategory);
xxHash.Append(buffer.AsSpan(0, 4));
BinaryPrimitives.WriteInt32LittleEndian(buffer.AsSpan(), maxEntry);
xxHash.Append(buffer.AsSpan(0, 4));
BinaryPrimitives.WriteInt32LittleEndian(buffer.AsSpan(), maxEpoch);
xxHash.Append(buffer.AsSpan(0, 4));

return xxHash.GetCurrentHashAsUInt32();
}

private static void AppendUtf8(XxHash32 xxHash, string text, byte[] buffer)
{
var bytes = Encoding.UTF8.GetBytes(text, 0, text.Length, buffer, 0);
xxHash.Append(buffer.AsSpan(0, bytes));
}

private static bool EnsureCategory(string category, Dictionary<string, int> map, List<string> list)
{
if (map.ContainsKey(category))
return false;

map[category] = list.Count;
list.Add(category);
return true;
}

private static bool EnsureEntry(string entry, Dictionary<string, int> map, List<string> list)
{
if (map.ContainsKey(entry))
return false;

map[entry] = list.Count;
list.Add(entry);
return true;
}

private static T? GetStaticField<T>(Type declaringType, string name)
where T : class
{
return AccessTools.DeclaredField(declaringType, name)?.GetValue(null) as T;
}

private static void SetStaticProperty(Type declaringType, string name, object value)
{
var prop = declaringType.GetProperty(name, BindingFlags.Public | BindingFlags.Static);
prop?.GetSetMethod(true)?.Invoke(null, [value]);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
using System.Collections.Concurrent;
using System.Reflection;
using HarmonyLib;
using MegaCrit.Sts2.Core.Models;
using MegaCrit.Sts2.Core.Random;
using MegaCrit.Sts2.Core.Rooms;
using STS2RitsuLib.Patching.Models;

namespace STS2RitsuLib.Interop.Patches
{
internal static class AndroidBaseLibLegacyGeneratedCompatHelper
{
private static readonly BindingFlags InstanceFlags =
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;

private static readonly ConcurrentDictionary<Type, LegacyEncounterCompatAccessors> EncounterAccessorCache = [];
private static readonly ConcurrentDictionary<MethodBase, bool> HasLegacyCompatOwnerCache = [];
private static readonly ConcurrentDictionary<string, byte> LoggedRoutes = [];
private static readonly ConcurrentDictionary<Type, MethodInfo?> OnUpgradeMethodCache = [];

internal static bool HasLegacyCompatOwner(MethodBase originalMethod)
{
return HasLegacyCompatOwnerCache.GetOrAdd(
originalMethod,
static method =>
{
var patchInfo = Harmony.GetPatchInfo(method);
if (patchInfo == null)
return false;

return patchInfo.Prefixes.Concat(patchInfo.Postfixes).Concat(patchInfo.Transpilers)
.Concat(patchInfo.Finalizers)
.Any(static patch =>
patch.owner.EndsWith(".BaseLibToRitsuCompat", StringComparison.Ordinal));
});
}

internal static void InvokeCardUpgradeFallback(CardModel card)
{
var onUpgrade = OnUpgradeMethodCache.GetOrAdd(
card.GetType(),
static type => type.GetMethod("OnUpgrade", InstanceFlags, null, Type.EmptyTypes, null));

onUpgrade?.Invoke(card, null);
card.FinalizeUpgradeInternal();

LogOnce(
$"{card.GetType().FullName}:upgrade",
$"[AndroidCompat] Routed CardModel.UpgradeInternal through reflection fallback for '{card.GetType().FullName}'.");
}

internal static BackgroundAssets BuildEncounterBackgroundFallback(EncounterModel encounter, ActModel parentAct,
Rng rng)
{
var accessors = EncounterAccessorCache.GetOrAdd(
encounter.GetType(),
static type => new LegacyEncounterCompatAccessors
{
PrepCustomBackground = type.GetMethod(
"PrepCustomBackground",
InstanceFlags,
null,
[typeof(ActModel), typeof(Rng)],
null),
GetPreparedBackgroundAssets = type.GetMethod(
"GetPreparedBackgroundAssets",
InstanceFlags,
null,
Type.EmptyTypes,
null),
});

accessors.PrepCustomBackground?.Invoke(encounter, [parentAct, rng]);

if (accessors.GetPreparedBackgroundAssets?.Invoke(encounter, null) is BackgroundAssets prepared)
{
LogOnce(
$"{encounter.GetType().FullName}:encounter-bg-custom",
$"[AndroidCompat] Routed EncounterModel.GetBackgroundAssets through legacy custom background fallback for '{encounter.GetType().FullName}'.");
return prepared;
}

LogOnce(
$"{encounter.GetType().FullName}:encounter-bg-act",
$"[AndroidCompat] Routed EncounterModel.GetBackgroundAssets to act background fallback for '{encounter.GetType().FullName}'.");
return parentAct.GenerateBackgroundAssets(rng);
}

private static void LogOnce(string key, string message)
{
if (!LoggedRoutes.TryAdd(key, 0))
return;

RitsuLibFramework.Logger.Info(message);
}

private sealed class LegacyEncounterCompatAccessors
{
public MethodInfo? GetPreparedBackgroundAssets { get; init; }
public MethodInfo? PrepCustomBackground { get; init; }
}
}

internal class AndroidBaseLibCardUpgradeInternalCompatibilityPatch : IPatchMethod
{
public static string PatchId => "android_baselib_card_upgrade_internal_compat";

public static bool IsCritical => false;

public static string Description =>
"Route CardModel.UpgradeInternal through reflection on Android when BaseLibToRitsu legacy patches are active";

public static ModPatchTarget[] GetTargets()
{
return [new(typeof(CardModel), nameof(CardModel.UpgradeInternal))];
}

[HarmonyPriority(Priority.First)]
public static bool Prefix(MethodBase __originalMethod, CardModel __instance)
{
if (!OperatingSystem.IsAndroid())
return true;

if (!AndroidBaseLibLegacyGeneratedCompatHelper.HasLegacyCompatOwner(__originalMethod))
return true;

try
{
AndroidBaseLibLegacyGeneratedCompatHelper.InvokeCardUpgradeFallback(__instance);
return false;
}
catch (Exception ex)
{
RitsuLibFramework.Logger.Warn(
$"[AndroidCompat] Card upgrade fallback failed for '{__instance.GetType().FullName}': {ex.GetBaseException().Message}");
return true;
}
}
}

internal class AndroidBaseLibEncounterGetBackgroundAssetsCompatibilityPatch : IPatchMethod
{
public static string PatchId => "android_baselib_encounter_get_background_assets_compat";

public static bool IsCritical => false;

public static string Description =>
"Route EncounterModel.GetBackgroundAssets through Android-safe BaseLibToRitsu fallback logic";

public static ModPatchTarget[] GetTargets()
{
return [new(typeof(EncounterModel), "GetBackgroundAssets", [typeof(ActModel), typeof(Rng)])];
}

[HarmonyPriority(Priority.First)]
public static bool Prefix(MethodBase __originalMethod, EncounterModel __instance, ActModel parentAct, Rng rng,
ref BackgroundAssets __result)
{
if (!OperatingSystem.IsAndroid())
return true;

if (!AndroidBaseLibLegacyGeneratedCompatHelper.HasLegacyCompatOwner(__originalMethod))
return true;

try
{
__result = AndroidBaseLibLegacyGeneratedCompatHelper.BuildEncounterBackgroundFallback(
__instance,
parentAct,
rng);
return false;
}
catch (Exception ex)
{
RitsuLibFramework.Logger.Warn(
$"[AndroidCompat] Encounter background fallback failed for '{__instance.GetType().FullName}': {ex.GetBaseException().Message}. Falling back to parent act background.");
__result = parentAct.GenerateBackgroundAssets(rng);
return false;
}
}
}
}
Loading