mirror of
https://git.ryujinx.app/projects/Kenji-NX.git
synced 2026-10-05 20:41:05 +02:00
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:
@@ -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);
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user