Skip to content

Commit c22eb11

Browse files
committed
chore(release): merge dev into main for v0.2.20
2 parents d347ded + 70ebace commit c22eb11

31 files changed

Lines changed: 1034 additions & 260 deletions

.github/workflows/issue-auto-label.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ jobs:
1212
runs-on: ubuntu-latest
1313
steps:
1414
- name: Parse issue form fields and label
15-
uses: actions/github-script@v7
15+
uses: actions/github-script@v9
1616
with:
1717
script: |
1818
const issue = context.payload.issue;

.github/workflows/labels-sync.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ jobs:
1515
fetch-depth: 1
1616

1717
- name: Apply labels.yml
18-
uses: crazy-max/ghaction-github-labeler@v5
18+
uses: crazy-max/ghaction-github-labeler@v6
1919
with:
2020
github-token: ${{ secrets.GITHUB_TOKEN }}
2121
yaml-file: .github/labels.yml
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
namespace STS2RitsuLib.Audio
2+
{
3+
/// <summary>
4+
/// Convenience helpers for loading FMOD Studio banks after the game has finished deferred initialization.
5+
/// </summary>
6+
public static class FmodStudioDeferredBankRegistration
7+
{
8+
/// <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).
12+
/// </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)
31+
{
32+
ArgumentException.ThrowIfNullOrWhiteSpace(bankResourcePath);
33+
34+
return QueueLoadBanksAfterDeferredInitialization(
35+
[bankResourcePath],
36+
studioGuidMappingsResourcePath,
37+
waitForAllLoadsAfterBanks);
38+
}
39+
40+
/// <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).
44+
/// </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)
60+
{
61+
ArgumentNullException.ThrowIfNull(bankResourcePaths);
62+
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));
66+
67+
return RitsuLibFramework.SubscribeDeferredInitializationOneShot(() =>
68+
{
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+
}
75+
76+
foreach (var path in banks)
77+
{
78+
if (string.IsNullOrWhiteSpace(path))
79+
continue;
80+
81+
if (!FmodStudioServer.TryLoadBank(path))
82+
RitsuLibFramework.Logger.Warn($"[Audio] Deferred FMOD bank load failed: {path}");
83+
}
84+
85+
if (waitForAllLoadsAfterBanks)
86+
FmodStudioServer.TryWaitForAllLoads();
87+
88+
if (string.IsNullOrWhiteSpace(studioGuidMappingsResourcePath))
89+
return;
90+
91+
if (!FmodStudioServer.TryLoadStudioGuidMappings(studioGuidMappingsResourcePath))
92+
RitsuLibFramework.Logger.Warn(
93+
$"[Audio] Deferred FMOD guid map failed: {studioGuidMappingsResourcePath}");
94+
});
95+
}
96+
}
97+
}

Audio/FmodStudioStreamingFiles.cs

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
using System.Collections.Concurrent;
22
using Godot;
33
using STS2RitsuLib.Audio.Internal;
4+
using FileAccess = Godot.FileAccess;
45

56
namespace STS2RitsuLib.Audio
67
{
78
/// <summary>
8-
/// Load loose audio files into the FMOD runtime (wav/ogg/mp3 per addon) from absolute filesystem paths.
9-
/// Rejects <c>res://</c>, but resolves <c>user://</c> to an absolute filesystem path first. Tracks loaded paths so
10-
/// you can unload deterministically.
9+
/// Load loose audio files into the FMOD runtime (wav/ogg/mp3 per addon). For <c>res://</c>, only paths that are
10+
/// still visible as raw files to <see cref="FileAccess" /> are accepted (e.g. Import dock &quot;Keep File (No Import)
11+
/// &quot;).
12+
/// Resolves <c>user://</c> to an absolute filesystem path. Tracks loaded paths so you can unload deterministically.
1113
/// </summary>
1214
public static class FmodStudioStreamingFiles
1315
{
@@ -81,7 +83,8 @@ public static bool TryPreloadAsStreamingMusic(string absolutePath)
8183
/// <summary>
8284
/// Returns a playable sound instance for the loose audio file at <paramref name="absolutePath" />, preloading as sound
8385
/// when needed.
84-
/// Accepts absolute filesystem paths and resolves <c>user://</c> to an absolute filesystem path.
86+
/// Accepts <c>res://</c> only when the path is a raw file for <see cref="FileAccess" />, absolute paths, and
87+
/// <c>user://</c> (globalized).
8588
/// </summary>
8689
public static GodotObject? TryCreateSoundInstance(string absolutePath)
8790
{
@@ -103,7 +106,8 @@ public static bool TryPreloadAsStreamingMusic(string absolutePath)
103106

104107
/// <summary>
105108
/// Returns a streaming music instance, preloading as music when needed.
106-
/// Accepts absolute filesystem paths and resolves <c>user://</c> to an absolute filesystem path.
109+
/// Accepts <c>res://</c> only when the path is a raw file for <see cref="FileAccess" />, absolute paths, and
110+
/// <c>user://</c> (globalized).
107111
/// </summary>
108112
public static GodotObject? TryCreateStreamingMusicInstance(string absolutePath)
109113
{
@@ -151,8 +155,11 @@ public static bool TryPlaySoundFile(string absolutePath, float volume = 1f, floa
151155
/// </summary>
152156
public static bool TryUnloadFile(string absolutePath)
153157
{
154-
return !Loaded.TryRemove(absolutePath, out _) ||
155-
FmodStudioGateway.TryCall(FmodStudioMethodNames.UnloadFile, absolutePath);
158+
if (!TryResolveSupportedPath(absolutePath, out var resolvedPath))
159+
return false;
160+
161+
return !Loaded.TryRemove(resolvedPath, out _) ||
162+
FmodStudioGateway.TryCall(FmodStudioMethodNames.UnloadFile, resolvedPath);
156163
}
157164

158165
/// <summary>
@@ -173,16 +180,42 @@ private static bool TryResolveSupportedPath(string path, out string resolvedPath
173180
return false;
174181
}
175182

176-
if (path.StartsWith("res://", StringComparison.OrdinalIgnoreCase))
183+
if (path.StartsWith("user://", StringComparison.OrdinalIgnoreCase))
177184
{
178-
RitsuLibFramework.Logger.Error($"[Audio] FMOD file playback does not accept res:// paths: {path}");
185+
resolvedPath = ProjectSettings.GlobalizePath(path);
186+
if (!Path.IsPathRooted(resolvedPath))
187+
{
188+
RitsuLibFramework.Logger.Error($"[Audio] FMOD file playback requires an absolute path: {path}");
189+
return false;
190+
}
191+
192+
if (File.Exists(resolvedPath)) return true;
193+
RitsuLibFramework.Logger.Error($"[Audio] FMOD file playback file not found: {resolvedPath}");
179194
return false;
180195
}
181196

182-
resolvedPath = path.StartsWith("user://", StringComparison.OrdinalIgnoreCase)
183-
? ProjectSettings.GlobalizePath(path)
184-
: path;
197+
if (path.StartsWith("res://", StringComparison.OrdinalIgnoreCase))
198+
{
199+
if (FileAccess.FileExists(path))
200+
{
201+
resolvedPath = path;
202+
return true;
203+
}
204+
205+
if (ResourceLoader.Exists(path))
206+
{
207+
RitsuLibFramework.Logger.Warn(
208+
"[Audio] FMOD file playback: path resolves only as imported/packed resource, not as a raw file for FileAccess. " +
209+
"Avoid default import for assets you stream through FMOD: use the Import dock \"Keep File (No Import)\" " +
210+
"(or ship a loose file / FMOD Studio bank). Path: " + path);
211+
return false;
212+
}
213+
214+
RitsuLibFramework.Logger.Error($"[Audio] FMOD file playback file not found: {path}");
215+
return false;
216+
}
185217

218+
resolvedPath = path;
186219
if (!Path.IsPathRooted(resolvedPath))
187220
{
188221
RitsuLibFramework.Logger.Error($"[Audio] FMOD file playback requires an absolute path: {path}");

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.19";
21+
public const string Version = "0.2.20";
2222

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

Content/DynamicCharacterStarterContentPatcher.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using MegaCrit.Sts2.Core.Models;
66
using STS2RitsuLib.Patching.Builders;
77
using STS2RitsuLib.Patching.Core;
8+
using STS2RitsuLib.Scaffolding.Characters;
89

910
namespace STS2RitsuLib.Content
1011
{
@@ -84,7 +85,7 @@ private static void TryAddCharacterPropertyGetter(
8485
Logger logger)
8586
{
8687
var getter = FindDeclaredPropertyGetter(concreteCharacterType, propertyName);
87-
if (getter == null || !queuedGetters.Add(getter))
88+
if (getter == null || IsModCharacterTemplateGetter(getter) || !queuedGetters.Add(getter))
8889
return;
8990

9091
try
@@ -118,6 +119,13 @@ private static void TryAddCharacterPropertyGetter(
118119
return null;
119120
}
120121

122+
private static bool IsModCharacterTemplateGetter(MethodInfo getter)
123+
{
124+
var declaringType = getter.DeclaringType;
125+
return declaringType is { IsGenericType: true }
126+
&& declaringType.GetGenericTypeDefinition() == typeof(ModCharacterTemplate<,,>);
127+
}
128+
121129
// ReSharper disable InconsistentNaming
122130
private static void StartingDeckPostfix(CharacterModel __instance, ref IEnumerable<CardModel> __result)
123131
// ReSharper restore InconsistentNaming

RitsuLibFramework.PatcherSetup.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,7 @@ private static void RegisterCharacterAssetPatches()
314314
patcher.RegisterPatch<NCreatureNonSpineReviveAnimationTriggerPatch>();
315315
patcher.RegisterPatch<ModMerchantCharacterVisualPlaybackPatch>();
316316
patcher.RegisterPatch<NMerchantRoomProceduralCharacterInstantiationPatch>();
317+
patcher.RegisterPatch<NFakeMerchantProceduralCharacterInstantiationPatch>();
317318
patcher.RegisterPatch<NRestSiteCharacterCreateProceduralPatch>();
318319
patcher.RegisterPatch<NRestSiteRoomProceduralVisualPlaybackPatch>();
319320
patcher.RegisterPatch<CardLibraryCompendiumPatch>();

RitsuLibFramework.cs

Lines changed: 69 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -100,16 +100,18 @@ public static IDisposable SubscribeLifecycle(ILifecycleObserver observer, bool r
100100
: [];
101101
}
102102

103-
foreach (var evt in lifecycleSnapshot)
104-
SafeNotify(observer, evt, evt.GetType().Name);
105-
106-
return new FrameworkLifecycleSubscription(() =>
103+
var observerSubscription = new FrameworkLifecycleSubscription(() =>
107104
{
108105
lock (SyncRoot)
109106
{
110107
_lifecycleObservers = RemoveItem(_lifecycleObservers, observer);
111108
}
112109
});
110+
111+
foreach (var evt in lifecycleSnapshot)
112+
SafeNotify(observer, evt, evt.GetType().Name);
113+
114+
return observerSubscription;
113115
}
114116

115117
/// <summary>
@@ -141,16 +143,77 @@ public static IDisposable SubscribeLifecycle<TEvent>(Action<TEvent> handler, boo
141143
ReplayableLifecycleEvents.TryGetValue(LifecycleEventTypeCache<TEvent>.EventType, out replayEvent);
142144
}
143145

146+
var subscription = new FrameworkLifecycleSubscription(() =>
147+
{
148+
lock (SyncRoot)
149+
{
150+
topic.Remove(handler);
151+
}
152+
});
153+
144154
if (replayEvent is TEvent typedReplayEvent)
145155
SafeNotify(handler, typedReplayEvent, LifecycleEventTypeCache<TEvent>.EventName);
146156

147-
return new FrameworkLifecycleSubscription(() =>
157+
return subscription;
158+
}
159+
160+
/// <summary>
161+
/// Registers <paramref name="handler" /> to run once after deferred initialization has completed, replaying
162+
/// immediately if that milestone was already reached.
163+
/// </summary>
164+
/// <param name="handler">Work to run exactly once.</param>
165+
/// <returns>
166+
/// Subscription token; it is also disposed automatically after <paramref name="handler" /> returns (including when
167+
/// replayed synchronously).
168+
/// </returns>
169+
/// <remarks>
170+
/// Use when a <see cref="MegaCrit.Sts2.Core.Modding.ModInitializerAttribute" /> entry point must wait for subsystems
171+
/// such as the
172+
/// Godot <c>FmodServer</c> singleton. Handlers that dispose the returned token from inside the callback can rely
173+
/// on the subscription object existing before any synchronous lifecycle replay runs.
174+
/// </remarks>
175+
public static IDisposable SubscribeDeferredInitializationOneShot(Action handler)
176+
{
177+
ArgumentNullException.ThrowIfNull(handler);
178+
179+
var topic = GetLifecycleTopic<DeferredInitializationCompletedEvent>();
180+
object? replayEvent;
181+
FrameworkLifecycleSubscription? subscription = null;
182+
183+
lock (SyncRoot)
184+
{
185+
topic.Add(Wrapped);
186+
187+
ReplayableLifecycleEvents.TryGetValue(
188+
LifecycleEventTypeCache<DeferredInitializationCompletedEvent>.EventType,
189+
out replayEvent);
190+
}
191+
192+
subscription = new(() =>
148193
{
149194
lock (SyncRoot)
150195
{
151-
topic.Remove(handler);
196+
topic.Remove(Wrapped);
152197
}
153198
});
199+
200+
if (replayEvent is DeferredInitializationCompletedEvent typedReplayEvent)
201+
SafeNotify(Wrapped, typedReplayEvent,
202+
LifecycleEventTypeCache<DeferredInitializationCompletedEvent>.EventName);
203+
204+
return subscription;
205+
206+
void Wrapped(DeferredInitializationCompletedEvent _)
207+
{
208+
try
209+
{
210+
handler();
211+
}
212+
finally
213+
{
214+
subscription?.Dispose();
215+
}
216+
}
154217
}
155218

156219
/// <summary>

STS2-RitsuLib.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323

2424
<PropertyGroup Label="NuGet package">
2525
<IsPackable>true</IsPackable>
26-
<Version>0.2.19</Version>
26+
<Version>0.2.20</Version>
2727
<Authors>OLC</Authors>
2828
<Description>Shared framework library for Slay the Spire 2 mods.</Description>
2929
<PackageReadmeFile>README.md</PackageReadmeFile>

0 commit comments

Comments
 (0)