Skip to content

Commit d143cc3

Browse files
committed
chore(release): merge dev into main for v0.2.21
2 parents c22eb11 + aed748c commit d143cc3

8 files changed

Lines changed: 259 additions & 124 deletions
Lines changed: 65 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,97 +1,91 @@
11
namespace STS2RitsuLib.Audio
22
{
33
/// <summary>
4-
/// Convenience helpers for loading FMOD Studio banks after the game has finished deferred initialization.
4+
/// Queues FMOD Studio bank and GUID mapping paths until <see cref="DeferredInitializationCompletedEvent" />,
5+
/// then loads them in one batch with a single <see cref="FmodStudioServer.TryWaitForAllLoads" />.
56
/// </summary>
67
public static class FmodStudioDeferredBankRegistration
78
{
9+
private static readonly Lock Gate = new();
10+
private static readonly HashSet<string> PendingBanks = new(StringComparer.Ordinal);
11+
private static readonly HashSet<string> PendingGuidFiles = new(StringComparer.Ordinal);
12+
private static bool _flushHookRegistered;
13+
814
/// <summary>
9-
/// Schedules loading one FMOD Studio bank and optional GUID path mappings once, after
10-
/// <see cref="STS2RitsuLib.DeferredInitializationCompletedEvent" /> (or immediately if that milestone already
11-
/// occurred).
15+
/// Queues a bank path to load after deferred initialization (deduplicated).
1216
/// </summary>
13-
/// <param name="bankResourcePath">
14-
/// Godot resource path to the <c>.bank</c> file (for example <c>res://Mod/audios/x.bank</c>
15-
/// ).
16-
/// </param>
17-
/// <param name="studioGuidMappingsResourcePath">
18-
/// Optional <c>GUIDs.txt</c>-style resource path; pass <c>null</c> when the bank does not need addon GUID mapping.
19-
/// </param>
20-
/// <param name="waitForAllLoadsAfterBanks">
21-
/// When true, calls <see cref="FmodStudioServer.TryWaitForAllLoads" /> after all banks in this batch have been
22-
/// submitted.
23-
/// </param>
24-
/// <returns>
25-
/// Subscription token; it is disposed automatically after the deferred load attempt finishes.
26-
/// </returns>
27-
public static IDisposable QueueLoadBankAfterDeferredInitialization(
28-
string bankResourcePath,
29-
string? studioGuidMappingsResourcePath = null,
30-
bool waitForAllLoadsAfterBanks = true)
17+
public static void RegisterBank(string resourcePath)
3118
{
32-
ArgumentException.ThrowIfNullOrWhiteSpace(bankResourcePath);
19+
if (string.IsNullOrWhiteSpace(resourcePath))
20+
return;
3321

34-
return QueueLoadBanksAfterDeferredInitialization(
35-
[bankResourcePath],
36-
studioGuidMappingsResourcePath,
37-
waitForAllLoadsAfterBanks);
22+
lock (Gate)
23+
{
24+
PendingBanks.Add(resourcePath.Trim());
25+
EnsureFlushHookRegisteredLocked();
26+
}
3827
}
3928

4029
/// <summary>
41-
/// Schedules loading multiple FMOD Studio banks (in order) and optional GUID path mappings once, after
42-
/// <see cref="STS2RitsuLib.DeferredInitializationCompletedEvent" /> (or immediately if that milestone already
43-
/// occurred).
30+
/// Queues a GUID mapping file for <see cref="FmodStudioServer.TryLoadStudioGuidMappings" /> after deferred
31+
/// initialization (deduplicated).
4432
/// </summary>
45-
/// <param name="bankResourcePaths">Non-empty sequence of Godot resource paths to <c>.bank</c> files.</param>
46-
/// <param name="studioGuidMappingsResourcePath">
47-
/// Optional <c>GUIDs.txt</c>-style resource path; pass <c>null</c> when no GUID table should be applied.
48-
/// </param>
49-
/// <param name="waitForAllLoadsAfterBanks">
50-
/// When true, calls <see cref="FmodStudioServer.TryWaitForAllLoads" /> after all banks in this batch have been
51-
/// submitted.
52-
/// </param>
53-
/// <returns>
54-
/// Subscription token; it is disposed automatically after the deferred load attempt finishes.
55-
/// </returns>
56-
public static IDisposable QueueLoadBanksAfterDeferredInitialization(
57-
IEnumerable<string> bankResourcePaths,
58-
string? studioGuidMappingsResourcePath = null,
59-
bool waitForAllLoadsAfterBanks = true)
33+
public static void RegisterStudioGuidMappings(string guidMapResourcePath)
34+
{
35+
if (string.IsNullOrWhiteSpace(guidMapResourcePath))
36+
return;
37+
38+
lock (Gate)
39+
{
40+
PendingGuidFiles.Add(guidMapResourcePath.Trim());
41+
EnsureFlushHookRegisteredLocked();
42+
}
43+
}
44+
45+
private static void EnsureFlushHookRegisteredLocked()
46+
{
47+
if (_flushHookRegistered)
48+
return;
49+
50+
_flushHookRegistered = true;
51+
RitsuLibFramework.SubscribeLifecycle<DeferredInitializationCompletedEvent>(_ => FlushPending());
52+
}
53+
54+
private static void FlushPending()
6055
{
61-
ArgumentNullException.ThrowIfNull(bankResourcePaths);
56+
if (FmodStudioServer.TryGet() is null)
57+
{
58+
RitsuLibFramework.Logger.Warn(
59+
"[Audio] deferred FMOD: FmodServer singleton missing; pending banks/GUID files kept for a later flush."
60+
);
61+
return;
62+
}
6263

63-
var banks = bankResourcePaths as string[] ?? bankResourcePaths.ToArray();
64-
if (banks.Length == 0)
65-
throw new ArgumentException("At least one bank path is required.", nameof(bankResourcePaths));
64+
List<string> banks;
65+
List<string> guids;
6666

67-
return RitsuLibFramework.SubscribeDeferredInitializationOneShot(() =>
67+
lock (Gate)
6868
{
69-
if (FmodStudioServer.TryGet() is null)
70-
{
71-
RitsuLibFramework.Logger.Warn(
72-
"[Audio] Deferred FMOD bank load skipped: FmodServer singleton is missing.");
73-
return;
74-
}
69+
banks = [.. PendingBanks];
70+
guids = [.. PendingGuidFiles];
71+
PendingBanks.Clear();
72+
PendingGuidFiles.Clear();
73+
}
7574

76-
foreach (var path in banks)
77-
{
78-
if (string.IsNullOrWhiteSpace(path))
79-
continue;
75+
if (banks.Count == 0 && guids.Count == 0)
76+
return;
8077

81-
if (!FmodStudioServer.TryLoadBank(path))
82-
RitsuLibFramework.Logger.Warn($"[Audio] Deferred FMOD bank load failed: {path}");
83-
}
78+
foreach (var path in banks)
79+
FmodStudioServer.TryLoadBank(path);
8480

85-
if (waitForAllLoadsAfterBanks)
86-
FmodStudioServer.TryWaitForAllLoads();
81+
foreach (var path in guids)
82+
FmodStudioServer.TryLoadStudioGuidMappings(path);
8783

88-
if (string.IsNullOrWhiteSpace(studioGuidMappingsResourcePath))
89-
return;
84+
FmodStudioServer.TryWaitForAllLoads();
9085

91-
if (!FmodStudioServer.TryLoadStudioGuidMappings(studioGuidMappingsResourcePath))
92-
RitsuLibFramework.Logger.Warn(
93-
$"[Audio] Deferred FMOD guid map failed: {studioGuidMappingsResourcePath}");
94-
});
86+
RitsuLibFramework.Logger.Info(
87+
$"[Audio] deferred FMOD flush complete (banks={banks.Count}, guid files={guids.Count})."
88+
);
9589
}
9690
}
9791
}

Audio/Internal/FmodStudioGuidPathTable.cs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,21 @@ internal static bool TryLoadFromResourceFile(string resourcePath)
4242
internal static void ParseAndReplace(string text, string? sourceLabel = null)
4343
{
4444
var lines = text.Replace("\r\n", "\n").Split('\n');
45-
var next = new Dictionary<string, string>(StringComparer.Ordinal);
45+
Dictionary<string, string> next;
46+
lock (Gate)
47+
{
48+
next = new(_eventPathToGuid, StringComparer.Ordinal);
49+
}
50+
4651
var guidKeyToFirstPath = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
52+
foreach (var kv in next)
53+
{
54+
if (!TryParseStoredGuid(kv.Value, out var parsed))
55+
continue;
56+
57+
guidKeyToFirstPath.TryAdd(parsed.ToString("N"), kv.Key);
58+
}
59+
4760
var prefix = string.IsNullOrEmpty(sourceLabel) ? "[Audio] guids.txt" : $"[Audio] guids.txt ({sourceLabel})";
4861

4962
for (var lineIndex = 0; lineIndex < lines.Length; lineIndex++)
@@ -106,6 +119,15 @@ internal static void ParseAndReplace(string text, string? sourceLabel = null)
106119
}
107120
}
108121

122+
private static bool TryParseStoredGuid(string stored, out Guid parsed)
123+
{
124+
var s = stored.AsSpan().Trim();
125+
if (s.Length >= 3 && s[0] == '{' && s[^1] == '}' && Guid.TryParse(s[1..^1], out parsed))
126+
return true;
127+
128+
return Guid.TryParse(s, out parsed);
129+
}
130+
109131
internal static IReadOnlyList<KeyValuePair<string, string>> SnapshotEventMappings()
110132
{
111133
lock (Gate)

Const.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ public static class Const
1818
/// <summary>
1919
/// Assembly / manifest version string.
2020
/// </summary>
21-
public const string Version = "0.2.20";
21+
public const string Version = "0.2.21";
2222

2323
/// <summary>
2424
/// Root key for RitsuLib JSON settings under the mod’s user folder.

FrameworkLifecycleContracts.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,24 @@ public void OnEvent(IFrameworkLifecycleEvent evt)
8181
}
8282
}
8383

84+
internal sealed class LifecycleSubscriptionHolder
85+
{
86+
public IDisposable Subscription { get; set; } = null!;
87+
}
88+
89+
internal sealed class DelegateLifecycleObserverWithSubscription<TEvent>(
90+
Action<TEvent, IDisposable> handler,
91+
LifecycleSubscriptionHolder holder
92+
) : ILifecycleObserver
93+
where TEvent : IFrameworkLifecycleEvent
94+
{
95+
public void OnEvent(IFrameworkLifecycleEvent evt)
96+
{
97+
if (evt is TEvent typedEvent)
98+
handler(typedEvent, holder.Subscription);
99+
}
100+
}
101+
84102
internal sealed class FrameworkLifecycleSubscription(Action unsubscribe) : IDisposable
85103
{
86104
private bool _disposed;

RitsuLibFramework.LifecycleOnce.cs

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
namespace STS2RitsuLib
2+
{
3+
public static partial class RitsuLibFramework
4+
{
5+
/// <summary>
6+
/// Subscribes a typed callback that runs at most once per returned subscription: after each invocation the
7+
/// subscription is disposed and the handler is removed.
8+
/// </summary>
9+
/// <typeparam name="TEvent">Concrete lifecycle event type (must be a struct or sealed class).</typeparam>
10+
/// <param name="handler">Invoked once when a matching event is delivered (including synchronous replay).</param>
11+
/// <param name="replayCurrentState">
12+
/// When true, invokes <paramref name="handler" /> once if a replayable last event exists, then disposes.
13+
/// </param>
14+
/// <returns>Disposing unsubscribes without invoking the handler.</returns>
15+
/// <exception cref="NotSupportedException">
16+
/// Thrown when <typeparamref name="TEvent" /> is not eligible for typed dispatch (same rule as
17+
/// <see cref="SubscribeLifecycle{TEvent}(Action{TEvent}, bool)" />).
18+
/// </exception>
19+
public static IDisposable SubscribeLifecycleOnce<TEvent>(
20+
Action<TEvent> handler,
21+
bool replayCurrentState = true
22+
)
23+
where TEvent : IFrameworkLifecycleEvent
24+
{
25+
ArgumentNullException.ThrowIfNull(handler);
26+
27+
if (!LifecycleEventTypeCache<TEvent>.SupportsTypedDispatch)
28+
throw new NotSupportedException(
29+
"SubscribeLifecycleOnce requires a sealed or struct lifecycle event type (typed dispatch). " +
30+
$"Unsupported type: {typeof(TEvent).FullName}."
31+
);
32+
33+
var topic = GetLifecycleTopic<TEvent>();
34+
FrameworkLifecycleSubscription? subscription = null;
35+
36+
void Wrapped(TEvent evt)
37+
{
38+
try
39+
{
40+
handler(evt);
41+
}
42+
finally
43+
{
44+
subscription?.Dispose();
45+
}
46+
}
47+
48+
object? replayEvent = null;
49+
50+
lock (SyncRoot)
51+
{
52+
subscription = new(() =>
53+
{
54+
lock (SyncRoot)
55+
{
56+
topic.Remove(Wrapped);
57+
}
58+
});
59+
60+
topic.Add(Wrapped);
61+
62+
if (replayCurrentState)
63+
ReplayableLifecycleEvents.TryGetValue(LifecycleEventTypeCache<TEvent>.EventType, out replayEvent);
64+
}
65+
66+
if (replayCurrentState && replayEvent is TEvent typedReplayEvent)
67+
SafeNotify(Wrapped, typedReplayEvent, LifecycleEventTypeCache<TEvent>.EventName);
68+
69+
return subscription;
70+
}
71+
}
72+
}

0 commit comments

Comments
 (0)