single MR with all patches i did, compared to the 2.0.5 release:

See merge request kenji-nx/ryujinx!18
This commit is contained in:
BeZide
2026-01-20 00:10:38 -06:00
committed by KeatonTheBot
parent dc4a51a462
commit 2c1b7bc667
38 changed files with 7401 additions and 598 deletions
+369 -1
View File
@@ -16,6 +16,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using System.Runtime.InteropServices;
using Ryujinx.Graphics.Vulkan;
namespace LibKenjinx
{
@@ -34,6 +35,254 @@ namespace LibKenjinx
public static VulkanLoader? VulkanLoader { get; private set; }
// ==== Audio Foreground/Background State (reflection-safe) ====
private static bool _audioPaused = false;
private static bool _audioMuted = false;
// strong reference if OpenAL backend is used
private static OpenALHardwareDeviceDriver? _openAl;
// ---------- helpers for broad reflection coverage ----------
private static readonly string[] PauseMethodCandidates =
{
"Pause", "SetPaused", "SetPause", "PauseAll", "RequestPause",
"SetIsPaused", "SetPauseState", "Suspend", "SetSuspended",
"PauseEmulation", "SetEmulationPaused", "SetRunning" // some implementations invert bool
};
private static readonly string[] VolumeMethodCandidates =
{
"SetVolumeMultiplier", "SetVolume", "SetMasterVolume", "SetGain"
};
private static readonly string[] VolumePropertyCandidates =
{
"VolumeMultiplier", "MasterVolume", "Volume", "Gain", "OutputVolume"
};
// Try call method with single bool
private static bool TryCallBool(object target, string[] names, bool arg)
{
if (target == null) return false;
var t = target.GetType();
foreach (var name in names)
{
var m = t.GetMethod(name, new[] { typeof(bool) });
if (m != null)
{
try { m.Invoke(target, new object[] { arg }); return true; } catch { }
}
}
return false;
}
// Try call method with single float
private static bool TryCallFloat(object target, string[] names, float arg)
{
if (target == null) return false;
var t = target.GetType();
foreach (var name in names)
{
var m = t.GetMethod(name, new[] { typeof(float) });
if (m != null)
{
try { m.Invoke(target, new object[] { arg }); return true; } catch { }
}
}
return false;
}
// Try set property (float/double)
private static bool TrySetFloatProp(object target, string[] names, float value)
{
if (target == null) return false;
var t = target.GetType();
foreach (var name in names)
{
var p = t.GetProperty(name);
if (p != null && p.CanWrite)
{
try
{
object v = value;
if (p.PropertyType == typeof(double)) v = (double)value;
p.SetValue(target, v);
return true;
}
catch { }
}
}
return false;
}
// Try set property (bool) for Mute/IsMuted etc.
private static bool TrySetBoolProp(object target, params string[] names)
{
if (target == null) return false;
var t = target.GetType();
foreach (var name in names)
{
var p = t.GetProperty(name);
if (p != null && p.CanWrite && p.PropertyType == typeof(bool))
{
try { p.SetValue(target, true); return true; } catch { }
}
}
return false;
}
// breadth-first walk through "audio-ish" objects accessible from a root
private static IEnumerable<object?> WalkAudioObjects(object? root, int depth = 2)
{
if (root == null || depth < 0) yield break;
yield return root;
var t = root.GetType();
var props = t.GetProperties();
foreach (var p in props)
{
object? val = null;
try { val = p.GetValue(root); } catch { /* ignore */ }
if (val == null) continue;
// Prefer properties that look audio-related or are common containers
if (p.Name.IndexOf("Audio", StringComparison.OrdinalIgnoreCase) >= 0 ||
p.Name.IndexOf("Sound", StringComparison.OrdinalIgnoreCase) >= 0 ||
p.Name.IndexOf("Mixer", StringComparison.OrdinalIgnoreCase) >= 0 ||
p.Name.IndexOf("Output", StringComparison.OrdinalIgnoreCase) >= 0 ||
p.PropertyType.Name.IndexOf("Audio", StringComparison.OrdinalIgnoreCase) >= 0)
{
foreach (var x in WalkAudioObjects(val, depth - 1))
yield return x;
}
}
}
/// <summary>
/// Attempts to pause the entire emulation (strongest guarantee to stop audio).
/// We probe several likely targets via reflection to be compatible across forks.
/// </summary>
private static bool TryPauseEmulation(bool pause)
{
int hits = 0;
try
{
// 1) Try SwitchDevice wrapper itself
var dev = SwitchDevice;
if (dev != null && TryCallBool(dev, PauseMethodCandidates, pause)) hits++;
// 2) Try underlying Switch or similar inner object
var inner =
dev?.GetType().GetProperty("Switch")?.GetValue(dev) ??
dev?.GetType().GetProperty("Device")?.GetValue(dev);
if (inner != null && TryCallBool(inner, PauseMethodCandidates, pause)) hits++;
// 3) Try EmulationContext and its "System" (kernel/front controller etc.)
var ctx = dev?.EmulationContext;
if (ctx != null)
{
if (TryCallBool(ctx, PauseMethodCandidates, pause)) hits++;
var sys = ctx.GetType().GetProperty("System")?.GetValue(ctx);
if (sys != null && TryCallBool(sys, PauseMethodCandidates, pause)) hits++;
// 4) walk all audio-ish descendants and try pause
foreach (var node in WalkAudioObjects(ctx, depth: 2))
{
if (node != null && TryCallBool(node, PauseMethodCandidates, pause))
hits++;
}
}
}
catch { /* ignore */ }
if (hits > 0)
Logger.Info?.Print(LogClass.Application, $"[PauseGate] Emulation pause={pause} hits={hits}");
return hits > 0;
}
/// <summary>
/// Applies paused/muted state. Tries many backends/locations to be
/// resilient across Ryujinx revisions.
/// </summary>
private static void ApplyAudioState()
{
bool shouldMute = _audioPaused || _audioMuted;
float vol = shouldMute ? 0f : 1f;
int hits = 0;
// A) Direct: OpenAL driver (if used)
try
{
var oal = _openAl;
if (oal != null)
{
bool p = TryCallBool(oal, PauseMethodCandidates, _audioPaused);
bool v = TryCallFloat(oal, VolumeMethodCandidates, vol) || TrySetFloatProp(oal, VolumePropertyCandidates, vol);
if (p || v) { hits++; Logger.Trace?.Print(LogClass.Application, $"[AudioGate] OpenAL applied p={p} v={v} vol={vol}"); }
}
}
catch { }
// B) EmulationContext managers (AudioRendererManager/AudioManager/AudioOutManager/…)
try
{
var ctx = SwitchDevice?.EmulationContext;
if (ctx != null)
{
foreach (var node in WalkAudioObjects(ctx, depth: 2))
{
if (node == null) continue;
bool p = TryCallBool(node, PauseMethodCandidates, _audioPaused);
bool v = TryCallFloat(node, VolumeMethodCandidates, vol) || TrySetFloatProp(node, VolumePropertyCandidates, vol);
// also try boolean mute-style properties (IsMuted/Mute)
if (shouldMute)
v = v || TrySetBoolProp(node, "IsMuted", "Muted", "Mute");
if (p || v) hits++;
}
}
}
catch { }
// C) Generic driver fallback (whatever AudioDriver actually is)
try
{
var drv = AudioDriver;
if (drv != null)
{
bool p = TryCallBool(drv, PauseMethodCandidates, _audioPaused);
bool v = TryCallFloat(drv, VolumeMethodCandidates, vol) || TrySetFloatProp(drv, VolumePropertyCandidates, vol);
if (shouldMute) v = v || TrySetBoolProp(drv, "IsMuted", "Muted", "Mute");
if (p || v) hits++;
}
}
catch { }
Logger.Info?.Print(LogClass.Application, $"[AudioGate] applied (hits={hits}) paused={_audioPaused} muted={_audioMuted} → vol={vol}");
// D) fallback: try to pause whole emulation if audio controls not hit
if (hits == 0 && _audioPaused)
{
if (TryPauseEmulation(true))
Logger.Info?.Print(LogClass.Application, "[AudioGate] escalated: Emulation paused");
}
else if (hits == 0 && !_audioPaused)
{
// try resume if we previously paused emulation
if (TryPauseEmulation(false))
Logger.Info?.Print(LogClass.Application, "[AudioGate] escalated: Emulation resumed");
}
}
// =============================================================
[DllImport("libkenjinxjni")]
internal extern static void setRenderingThread();
@@ -96,6 +345,7 @@ namespace LibKenjinx
debug_break(4);
Logger.Trace?.Print(LogClass.Application, "Jni Function Call");
AudioDriver = new OpenALHardwareDeviceDriver();
_openAl = AudioDriver as OpenALHardwareDeviceDriver; // <-- audio patch: keep a strong ref
var timezone = Marshal.PtrToStringAnsi(timeZonePtr);
return InitializeDevice((MemoryManagerMode)memoryManagerMode,
@@ -316,7 +566,7 @@ namespace LibKenjinx
Window = (nint*)_surfacePtr,
};
var result = surfaceExtension.CreateAndroidSurface(new Instance(instance), createInfo, null, out var surface);
var result = surfaceExtension.CreateAndroidSurface(new Instance(instance), in createInfo, null, out var surface);
// If a rotation was applied before the surface was created → apply it now
if (_window != 0 && _pendingRotationDegrees != -1)
@@ -565,6 +815,22 @@ namespace LibKenjinx
DeleteUser(userId);
}
// --- Audio JNI Exports (Foreground/Background gating) ---
[UnmanagedCallersOnly(EntryPoint = "audioSetPaused")]
public static void JniAudioSetPaused(bool paused)
{
_audioPaused = paused;
ApplyAudioState();
}
[UnmanagedCallersOnly(EntryPoint = "audioSetMuted")]
public static void JniAudioSetMuted(bool muted)
{
_audioMuted = muted;
ApplyAudioState();
}
// ---------------------------------------------------------
[UnmanagedCallersOnly(EntryPoint = "uiHandlerSetup")]
public static void JniSetupUiHandler()
{
@@ -688,9 +954,111 @@ namespace LibKenjinx
Logger.Error?.Print(LogClass.Application, $"deviceRecreateSwapchain failed: {ex}");
}
}
// ===== PresentAllowed / Surface Control (JNI) =====
// Alias for older calls, if available
[UnmanagedCallersOnly(EntryPoint = "graphicsRendererSetPresent")]
public static void JniGraphicsRendererSetPresent(bool enabled)
{
try
{
if (Renderer is VulkanRenderer vr)
{
vr.SetPresentEnabled(enabled);
Logger.Trace?.Print(LogClass.Application, $"[JNI] PresentEnabled = {enabled}");
}
}
catch (Exception ex)
{
Logger.Warning?.Print(LogClass.Application, $"graphicsRendererSetPresent failed: {ex}");
}
}
// New name: Matches KenjinxNative.graphicsSetPresentEnabled(...)
[UnmanagedCallersOnly(EntryPoint = "graphicsSetPresentEnabled")]
public static void JniGraphicsSetPresentEnabled(bool enabled)
{
try
{
(Renderer as VulkanRenderer)?.SetPresentEnabled(enabled);
Logger.Trace?.Print(LogClass.Application, $"[JNI] graphicsSetPresentEnabled({enabled})");
}
catch (Exception ex)
{
Logger.Warning?.Print(LogClass.Application, $"graphicsSetPresentEnabled failed: {ex}");
}
}
[UnmanagedCallersOnly(EntryPoint = "graphicsRendererRecreateSurface")]
public static void JniGraphicsRendererRecreateSurface()
{
try
{
_ = (Renderer as VulkanRenderer)?.RecreateSurface();
}
catch (Exception ex)
{
Logger.Warning?.Print(LogClass.Application, $"graphicsRendererRecreateSurface failed: {ex}");
}
}
// Used by MainActivity/GameHost
[UnmanagedCallersOnly(EntryPoint = "reattachWindowIfReady")]
public static bool JniReattachWindowIfReady()
{
try
{
return (Renderer as VulkanRenderer)?.RecreateSurface() ?? false;
}
catch (Exception ex)
{
Logger.Warning?.Print(LogClass.Application, $"reattachWindowIfReady failed: {ex}");
return false;
}
}
[UnmanagedCallersOnly(EntryPoint = "detachWindow")]
public static void JniDetachWindow()
{
try
{
(Renderer as VulkanRenderer)?.ReleaseSurface();
}
catch (Exception ex)
{
Logger.Warning?.Print(LogClass.Application, $"detachWindow failed: {ex}");
}
}
// ===== Amiibo JNI Exports =====
[UnmanagedCallersOnly(EntryPoint = "amiiboLoadBin")]
public static bool JniAmiiboLoadBin(IntPtr dataPtr, int length)
{
if (dataPtr == IntPtr.Zero || length <= 0) return false;
try
{
byte[] buf = new byte[length];
Marshal.Copy(dataPtr, buf, 0, length);
return AmiiboLoadFromBytes(buf);
}
catch
{
return false;
}
}
[UnmanagedCallersOnly(EntryPoint = "amiiboClear")]
public static void JniAmiiboClear()
{
AmiiboClear();
}
// ===== End Amiibo JNI Exports =====
}
internal static partial class Logcat
{
[LibraryImport("liblog", StringMarshalling = StringMarshalling.Utf8)]
private static partial void __android_log_print(LogLevel level, string? tag, string format, string args, IntPtr ptr);
+34 -5
View File
@@ -61,7 +61,16 @@ namespace LibKenjinx
}
else if (graphicsBackend == GraphicsBackend.Vulkan)
{
Renderer = new VulkanRenderer(Vk.GetApi(), (instance, _) => new SurfaceKHR(createSurfaceFunc == null ? null : (ulong?)createSurfaceFunc(instance.Handle)),
// Prefer the platform-provided Vulkan loader (if present), fall back to default.
var api = VulkanLoader?.GetApi() ?? Vk.GetApi();
Renderer = new VulkanRenderer(
api,
(instance, _) =>
{
// use provided CreateSurface delegate (Android path will create ANativeWindow surface)
return new SurfaceKHR(createSurfaceFunc == null ? null : (ulong?)createSurfaceFunc(instance.Handle));
},
() => requiredExtensions,
null);
}
@@ -146,9 +155,9 @@ namespace LibKenjinx
}
}
if (device.Gpu.Renderer is ThreadedRenderer threaded)
if (device.Gpu.Renderer is ThreadedRenderer tr)
{
threaded.FlushThreadedCommands();
tr.FlushThreadedCommands();
}
_gpuDoneEvent.Set();
@@ -165,7 +174,7 @@ namespace LibKenjinx
{
void SetInfo(string status, float value)
{
if(PlatformInfo.IsBionic)
if (PlatformInfo.IsBionic)
{
Interop.UpdateProgress(status, value);
}
@@ -178,7 +187,7 @@ namespace LibKenjinx
switch (state)
{
case LoadState ptcState:
if (float.IsNaN((progress)))
if (float.IsNaN(progress))
progress = 0;
switch (ptcState)
@@ -216,6 +225,26 @@ namespace LibKenjinx
{
_swapBuffersCallback = swapBuffersCallback;
}
// ===== Convenience wrapper for Vulkan re-attachment (usable from JNI) =====
public static bool TryReattachSurface()
{
if (Renderer is VulkanRenderer vr)
{
return vr.RecreateSurface();
}
return false;
}
public static void ReleaseRendererSurface()
{
(Renderer as VulkanRenderer)?.ReleaseSurface();
}
public static void SetPresentEnabled(bool enabled)
{
(Renderer as VulkanRenderer)?.SetPresentEnabled(enabled);
}
}
[StructLayout(LayoutKind.Sequential)]
+190 -138
View File
@@ -20,6 +20,7 @@ using Ryujinx.Common.Logging.Targets;
using Ryujinx.Common.Utilities;
using Ryujinx.Graphics.GAL.Multithreading;
using Ryujinx.HLE;
using Ryujinx.HLE.Kenjinx;
using Ryujinx.HLE.FileSystem;
using Ryujinx.HLE.HOS;
using Ryujinx.HLE.HOS.Services.Account.Acc;
@@ -690,9 +691,64 @@ namespace LibKenjinx
uiHandler.SetResponse(isOkPressed, input);
}
}
// ===== Amiibo Helpers (Kenjinx) =====
public static bool AmiiboLoadFromBytes(byte[] data)
{
if (data == null || data.Length == 0)
{
Logger.Warning?.Print(LogClass.Service, "[Amiibo] Load aborted: empty data.");
return false;
}
var dev = SwitchDevice?.EmulationContext;
if (dev == null)
{
Logger.Warning?.Print(LogClass.Service, "[Amiibo] Load aborted: no active EmulationContext.");
return false;
}
try
{
var ok = AmiiboBridge.TryLoadVirtualAmiibo(dev, data, out string msg);
if (ok)
Logger.Info?.Print(LogClass.Service, $"[Amiibo] Loaded {data.Length} bytes. {msg}");
else
Logger.Warning?.Print(LogClass.Service, $"[Amiibo] Injection failed. {msg}");
return ok;
}
catch (Exception ex)
{
Logger.Error?.Print(LogClass.Service, $"[Amiibo] Exception: {ex}");
return false;
}
}
public static void AmiiboClear()
{
var dev = SwitchDevice?.EmulationContext;
if (dev == null)
{
Logger.Warning?.Print(LogClass.Service, "[Amiibo] Clear aborted: no active EmulationContext.");
return;
}
try
{
AmiiboBridge.ClearVirtualAmiibo(dev);
Logger.Info?.Print(LogClass.Service, "[Amiibo] Cleared.");
}
catch (Exception ex)
{
Logger.Error?.Print(LogClass.Service, $"[Amiibo] Clear exception: {ex}");
}
}
// ===== End Amiibo Helpers =====
}
public class SwitchDevice : IDisposable
{
private readonly SystemVersion _firmwareVersion;
public VirtualFileSystem VirtualFileSystem { get; set; }
@@ -966,156 +1022,152 @@ namespace LibKenjinx
.Replace("\n", "\\n");
}
/// <summary>
/// Updates .../save/titleid_map.ndjson in NDJSON format:
/// - Reads existing lines
/// - Replaces/adds entry for titleId
/// - Completely rewrites the file (no unlimited size increase)
/// - NEVER overwrites the folder with an empty value; attempts to determine it via markers
/// </summary>
private static void UpsertTitleMapNdjson(string savesRoot, string titleIdHex, string titleName, string createdFolder)
{
Directory.CreateDirectory(savesRoot);
string mapPath = Path.Combine(savesRoot, "titleid_map.ndjson");
// titleId (lowercase) -> (Name, Folder, Timestamp)
var byTitleId = new Dictionary<string, (string Name, string Folder, string Timestamp)>(StringComparer.OrdinalIgnoreCase);
// Bestehende Datei einlesen
try
{
if (File.Exists(mapPath))
/// <summary>
/// Updates .../save/titleid_map.ndjson in NDJSON format:
/// - Reads existing lines
/// - Replaces/adds entry for titleId
/// - Completely rewrites the file (no unlimited size increase)
/// - NEVER overwrites the folder with an empty value; attempts to determine it via markers
/// </summary>
private static void UpsertTitleMapNdjson(string savesRoot, string titleIdHex, string titleName, string createdFolder)
{
foreach (var line in File.ReadLines(mapPath, Encoding.UTF8))
{
if (string.IsNullOrWhiteSpace(line)) continue;
Directory.CreateDirectory(savesRoot);
string mapPath = Path.Combine(savesRoot, "titleid_map.ndjson");
try
{
using var doc = JsonDocument.Parse(line);
var root = doc.RootElement;
string tid = root.TryGetProperty("titleId", out var tidEl) ? (tidEl.GetString() ?? "").Trim() : "";
if (string.IsNullOrEmpty(tid)) continue;
string name = root.TryGetProperty("name", out var nameEl) ? (nameEl.GetString() ?? "") : "";
string folder = root.TryGetProperty("folder", out var folderEl) ? (folderEl.GetString() ?? "") : "";
string ts = root.TryGetProperty("timestamp", out var tsEl) ? (tsEl.GetString() ?? "") : "";
byTitleId[tid.ToLowerInvariant()] = (name, folder, ts);
}
catch
{
// Korrupten Eintrag ignorieren
}
}
}
}
catch
{
byTitleId.Clear();
}
var nowIso = DateTime.UtcNow.ToString("O");
var titleIdLc = (titleIdHex ?? string.Empty).ToLowerInvariant();
// 1) Bestimme den "bestehenden" Ordner aus der Map (falls vorhanden)
byTitleId.TryGetValue(titleIdLc, out var existing);
string existingFolder = existing.Folder ?? "";
// 2) Versuche, einen sinnvollen Ordner zu bestimmen:
// a) frisch erstellter Ordnername
// b) per Markerdatei in den Saves ermitteln
// c) bisherigen (nicht-leeren) Wert beibehalten
string effectiveFolder = createdFolder;
if (string.IsNullOrWhiteSpace(effectiveFolder))
{
effectiveFolder = ResolveSaveFolderByMarker(savesRoot, titleIdLc);
}
if (string.IsNullOrWhiteSpace(effectiveFolder) && !string.IsNullOrWhiteSpace(existingFolder))
{
effectiveFolder = existingFolder;
}
// 3) Markerdatei sicherstellen, falls Ordner ermittelt
try
{
if (!string.IsNullOrWhiteSpace(effectiveFolder))
{
string markerPath = Path.Combine(savesRoot, effectiveFolder, "TITLEID.txt");
if (!File.Exists(markerPath))
{
Directory.CreateDirectory(Path.GetDirectoryName(markerPath)!);
File.WriteAllText(markerPath, $"{titleIdLc}\n{titleName ?? "Unknown"}");
}
}
}
catch
{
// Marker-Erstellung darf das Gameplay nicht stören
}
// 4) Upsert: niemals mit leerem Folder überschreiben
string finalName = string.IsNullOrWhiteSpace(titleName) ? (existing.Name ?? "") : titleName;
string finalFolder = string.IsNullOrWhiteSpace(effectiveFolder) ? (existing.Folder ?? "") : effectiveFolder;
string finalTs = nowIso;
byTitleId[titleIdLc] = (finalName ?? "", finalFolder ?? "", finalTs);
// 5) Datei vollständig neu schreiben (stabil: nach titleId sortiert)
try
{
var ordered = byTitleId
.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase)
.Select(kv =>
$"{{\"titleId\":\"{EscapeJson(kv.Key)}\",\"name\":\"{EscapeJson(kv.Value.Name)}\"," +
$"\"folder\":\"{EscapeJson(kv.Value.Folder)}\",\"timestamp\":\"{EscapeJson(kv.Value.Timestamp)}\"}}{Environment.NewLine}"
);
File.WriteAllText(mapPath, string.Concat(ordered), Encoding.UTF8);
}
catch
{
// Schreibfehler stillschweigend ignorieren
}
}
/// <summary>
/// Durchsucht alle Save-Ordner nach einer TITLEID.txt, deren erste Zeile der titleId entspricht.
/// Gibt den Ordnernamen (z.B. "00000012") zurück oder null.
/// </summary>
private static string ResolveSaveFolderByMarker(string savesRoot, string titleIdLc)
{
try
{
if (!Directory.Exists(savesRoot)) return null;
foreach (var dir in Directory.GetDirectories(savesRoot))
{
string marker = Path.Combine(dir, "TITLEID.txt");
if (!File.Exists(marker)) continue;
// titleId (lowercase) -> (Name, Folder, Timestamp)
var byTitleId = new Dictionary<string, (string Name, string Folder, string Timestamp)>(StringComparer.OrdinalIgnoreCase);
// Read existing file
try
{
using var sr = new StreamReader(marker, Encoding.UTF8, true);
string first = sr.ReadLine()?.Trim()?.ToLowerInvariant();
if (first == titleIdLc)
if (File.Exists(mapPath))
{
return Path.GetFileName(dir);
foreach (var line in File.ReadLines(mapPath, Encoding.UTF8))
{
if (string.IsNullOrWhiteSpace(line)) continue;
try
{
using var doc = JsonDocument.Parse(line);
var root = doc.RootElement;
string tid = root.TryGetProperty("titleId", out var tidEl) ? (tidEl.GetString() ?? "").Trim() : "";
if (string.IsNullOrEmpty(tid)) continue;
string name = root.TryGetProperty("name", out var nameEl) ? (nameEl.GetString() ?? "") : "";
string folder = root.TryGetProperty("folder", out var folderEl) ? (folderEl.GetString() ?? "") : "";
string ts = root.TryGetProperty("timestamp", out var tsEl) ? (tsEl.GetString() ?? "") : "";
byTitleId[tid.ToLowerInvariant()] = (name, folder, ts);
}
catch
{
// ignore corrupt entry
}
}
}
}
catch
{
// ignorieren und weiter
byTitleId.Clear();
}
var nowIso = DateTime.UtcNow.ToString("O");
var titleIdLc = (titleIdHex ?? string.Empty).ToLowerInvariant();
// 1) take existing folder if known
byTitleId.TryGetValue(titleIdLc, out var existing);
string existingFolder = existing.Folder ?? "";
// 2) figure out effective folder
string effectiveFolder = createdFolder;
if (string.IsNullOrWhiteSpace(effectiveFolder))
{
effectiveFolder = ResolveSaveFolderByMarker(savesRoot, titleIdLc);
}
if (string.IsNullOrWhiteSpace(effectiveFolder) && !string.IsNullOrWhiteSpace(existingFolder))
{
effectiveFolder = existingFolder;
}
// 3) ensure marker in the folder
try
{
if (!string.IsNullOrWhiteSpace(effectiveFolder))
{
string markerPath = Path.Combine(savesRoot, effectiveFolder, "TITLEID.txt");
if (!File.Exists(markerPath))
{
Directory.CreateDirectory(Path.GetDirectoryName(markerPath)!);
File.WriteAllText(markerPath, $"{titleIdLc}\n{titleName ?? "Unknown"}");
}
}
}
catch
{
// ignore
}
// 4) upsert (never overwrite with empty folder)
string finalName = string.IsNullOrWhiteSpace(titleName) ? (existing.Name ?? "") : titleName;
string finalFolder = string.IsNullOrWhiteSpace(effectiveFolder) ? (existing.Folder ?? "") : effectiveFolder;
string finalTs = nowIso;
byTitleId[titleIdLc] = (finalName ?? "", finalFolder ?? "", finalTs);
// 5) rewrite file (stable: sort by titleId)
try
{
var ordered = byTitleId
.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase)
.Select(kv =>
$"{{\"titleId\":\"{EscapeJson(kv.Key)}\",\"name\":\"{EscapeJson(kv.Value.Name)}\"," +
$"\"folder\":\"{EscapeJson(kv.Value.Folder)}\",\"timestamp\":\"{EscapeJson(kv.Value.Timestamp)}\"}}{Environment.NewLine}"
);
File.WriteAllText(mapPath, string.Concat(ordered), Encoding.UTF8);
}
catch
{
// ignore write errors
}
}
}
catch
{
// ignorieren
}
return null;
}
/// <summary>
/// Scans save subfolders for a TITLEID.txt whose first line equals the titleId; returns folder name or null.
/// </summary>
private static string ResolveSaveFolderByMarker(string savesRoot, string titleIdLc)
{
try
{
if (!Directory.Exists(savesRoot)) return null;
foreach (var dir in Directory.GetDirectories(savesRoot))
{
string marker = Path.Combine(dir, "TITLEID.txt");
if (!File.Exists(marker)) continue;
try
{
using var sr = new StreamReader(marker, Encoding.UTF8, true);
string first = sr.ReadLine()?.Trim()?.ToLowerInvariant();
if (first == titleIdLc)
{
return Path.GetFileName(dir);
}
}
catch
{
// ignore and continue
}
}
}
catch
{
// ignore
}
return null;
}
/// <summary>