Skip to content

Commit 5e73681

Browse files
committed
feat(bundle): introduce multi-variant RitsuLib loader and update build process
- Added a new loader assembly to dynamically load the appropriate RitsuLib variant based on the running game version. - Updated project files to include the new loader project and adjusted the solution structure accordingly. - Enhanced the build scripts to support packaging of the loader and its dependencies into a single bundle for easier distribution. - Modified the README to document the new runtime bundle installation process for end users.
1 parent 6958d1b commit 5e73681

12 files changed

Lines changed: 430 additions & 4 deletions

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66
*.sln.DotSettings.user
77
artifacts/nuget/
88
artifacts/github/
9+
artifacts/bundle-staging/
10+
11+
Loader/bin/
12+
Loader/obj/
913

1014
.vs/
1115

Loader/Bootstrap.cs

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
using System.Reflection;
2+
using System.Runtime.Loader;
3+
using MegaCrit.Sts2.Core.Logging;
4+
using MegaCrit.Sts2.Core.Modding;
5+
using STS2RitsuLib.Compat;
6+
7+
namespace STS2RitsuLib.Loader;
8+
9+
/// <summary>
10+
/// Entry assembly for the multi-variant RitsuLib bundle: loads the matching <c>STS2-RitsuLib.dll</c> from
11+
/// <c>lib/&lt;compat&gt;/</c> into the default ALC, then forwards to the real framework initializer.
12+
/// </summary>
13+
[ModInitializer(nameof(Initialize))]
14+
public static class Bootstrap
15+
{
16+
public static void Initialize()
17+
{
18+
var loaderDir = Path.GetDirectoryName(typeof(Bootstrap).Assembly.Location);
19+
if (string.IsNullOrEmpty(loaderDir))
20+
{
21+
Log.Error("[RitsuLib.Loader] Could not resolve loader directory.");
22+
return;
23+
}
24+
25+
var libRoot = Path.Combine(loaderDir, "lib");
26+
if (!Directory.Exists(libRoot))
27+
{
28+
Log.Error($"[RitsuLib.Loader] Missing lib directory: {libRoot}");
29+
return;
30+
}
31+
32+
var hostNumeric = Sts2HostVersion.Numeric;
33+
var hostLabel = Sts2HostVersion.ReleaseLabel;
34+
var pickedDir = PickVariantDir(libRoot, hostNumeric);
35+
if (pickedDir is null)
36+
{
37+
Log.Error($"[RitsuLib.Loader] No compatible variant under {libRoot} (host={(hostLabel ?? hostNumeric?.ToString()) ?? "unknown"}).");
38+
return;
39+
}
40+
41+
var pickedName = Path.GetFileName(pickedDir.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
42+
Log.Info(
43+
$"[RitsuLib.Loader] Host version label={hostLabel ?? "<none>"} numeric={hostNumeric?.ToString() ?? "<none>"}; picked variant {pickedName}.");
44+
45+
var realDll = Path.Combine(pickedDir, "STS2-RitsuLib.dll");
46+
if (!File.Exists(realDll))
47+
{
48+
Log.Error($"[RitsuLib.Loader] Variant folder missing STS2-RitsuLib.dll: {realDll}");
49+
return;
50+
}
51+
52+
var alc = AssemblyLoadContext.GetLoadContext(typeof(Bootstrap).Assembly) ?? AssemblyLoadContext.Default;
53+
Assembly realAsm;
54+
try
55+
{
56+
realAsm = alc.LoadFromAssemblyPath(realDll);
57+
}
58+
catch (Exception ex)
59+
{
60+
Log.Error($"[RitsuLib.Loader] Failed to load {realDll}: {ex}");
61+
return;
62+
}
63+
64+
try
65+
{
66+
InvokeRealInitializer(realAsm);
67+
}
68+
catch (Exception ex)
69+
{
70+
Log.Error($"[RitsuLib.Loader] Failed to initialize real RitsuLib: {ex}");
71+
}
72+
}
73+
74+
private static void InvokeRealInitializer(Assembly realAsm)
75+
{
76+
Type[] types;
77+
try
78+
{
79+
types = realAsm.GetTypes();
80+
}
81+
catch (ReflectionTypeLoadException ex)
82+
{
83+
Log.Error($"[RitsuLib.Loader] ReflectionTypeLoadException while scanning {realAsm.FullName}: {ex}");
84+
if (ex.Types is not null)
85+
{
86+
foreach (var t in ex.Types.Where(static x => x is not null))
87+
TryInvokeInitializerOnType(t!);
88+
}
89+
90+
return;
91+
}
92+
93+
foreach (var t in types)
94+
{
95+
if (TryInvokeInitializerOnType(t))
96+
return;
97+
}
98+
99+
Log.Error($"[RitsuLib.Loader] No type with {nameof(ModInitializerAttribute)} found in {realAsm.FullName}.");
100+
}
101+
102+
private static bool TryInvokeInitializerOnType(Type t)
103+
{
104+
var attr = t.GetCustomAttribute<ModInitializerAttribute>();
105+
if (attr is null)
106+
return false;
107+
108+
var method = t.GetMethod(attr.initializerMethod, BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic);
109+
if (method is null)
110+
{
111+
Log.Error(
112+
$"[RitsuLib.Loader] Type {t.FullName} has {nameof(ModInitializerAttribute)} but no static method {attr.initializerMethod}.");
113+
return false;
114+
}
115+
116+
method.Invoke(null, null);
117+
return true;
118+
}
119+
120+
private static string? PickVariantDir(string libRoot, Version? host)
121+
{
122+
var dirs = new List<(string Path, Version Ver)>();
123+
foreach (var d in Directory.EnumerateDirectories(libRoot))
124+
{
125+
var name = Path.GetFileName(d.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
126+
if (string.IsNullOrEmpty(name))
127+
continue;
128+
if (!Sts2HostVersion.TryParseVersionCore(name, out var v))
129+
continue;
130+
dirs.Add((d, v));
131+
}
132+
133+
if (dirs.Count == 0)
134+
return null;
135+
136+
dirs.Sort(static (a, b) => a.Ver.CompareTo(b.Ver));
137+
138+
if (host is null)
139+
{
140+
Log.Info("[RitsuLib.Loader] Host numeric version unknown; using newest bundled variant.");
141+
return dirs[^1].Path;
142+
}
143+
144+
var candidates = dirs.Where(x => x.Ver <= host).ToList();
145+
if (candidates.Count > 0)
146+
return candidates[^1].Path;
147+
148+
Log.Info(
149+
$"[RitsuLib.Loader] No bundled variant <= host {host}; using newest bundled variant as best-effort fallback.");
150+
return dirs[^1].Path;
151+
}
152+
}

Loader/STS2-RitsuLib-Loader.csproj

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
<PropertyGroup>
3+
<TargetFramework>net9.0</TargetFramework>
4+
<ImplicitUsings>enable</ImplicitUsings>
5+
<Nullable>enable</Nullable>
6+
<AssemblyName>STS2-RitsuLib.Loader</AssemblyName>
7+
<RootNamespace>STS2RitsuLib.Loader</RootNamespace>
8+
<EnableDynamicLoading>true</EnableDynamicLoading>
9+
<IsPackable>false</IsPackable>
10+
<GenerateDocumentationFile>false</GenerateDocumentationFile>
11+
<!-- Compile against oldest supported API signatures for stable surface. -->
12+
<Sts2ApiCompat>0.103.2</Sts2ApiCompat>
13+
<Sts2ApiSignatureDir Condition="'$(Sts2ApiSignatureRoot)' != ''">$(Sts2ApiSignatureRoot)\$(Sts2ApiCompat)</Sts2ApiSignatureDir>
14+
<Sts2DataDir Condition="'$(Sts2ApiSignatureRoot)' != ''">$(Sts2ApiSignatureDir)</Sts2DataDir>
15+
</PropertyGroup>
16+
17+
<Target Name="ValidateSts2ApiSignatures" BeforeTargets="ResolveAssemblyReferences">
18+
<Error
19+
Condition="'$(Sts2ApiSignatureRoot)' != '' and !Exists('$(Sts2ApiSignatureRoot)')"
20+
Text="Sts2ApiSignatureRoot is set but the directory does not exist: '$(Sts2ApiSignatureRoot)'."/>
21+
<Error
22+
Condition="'$(Sts2ApiSignatureRoot)' != '' and Exists('$(Sts2ApiSignatureRoot)') and !Exists('$(Sts2ApiSignatureDir)\sts2.dll')"
23+
Text="Sts2ApiSignatureRoot is set but missing expected file: '$(Sts2ApiSignatureDir)\sts2.dll'."/>
24+
<Error
25+
Condition="'$(Sts2ApiSignatureRoot)' != '' and Exists('$(Sts2ApiSignatureRoot)') and !Exists('$(Sts2ApiSignatureDir)\0Harmony.dll')"
26+
Text="Sts2ApiSignatureRoot is set but missing expected file: '$(Sts2ApiSignatureDir)\0Harmony.dll'."/>
27+
<Error
28+
Condition="'$(Sts2ApiSignatureRoot)' != '' and Exists('$(Sts2ApiSignatureRoot)') and !Exists('$(Sts2ApiSignatureDir)\Steamworks.NET.dll')"
29+
Text="Sts2ApiSignatureRoot is set but missing expected file: '$(Sts2ApiSignatureDir)\Steamworks.NET.dll'."/>
30+
</Target>
31+
<Target Name="ValidateSts2GameInstall" BeforeTargets="ResolveAssemblyReferences">
32+
<Error
33+
Condition="!Exists('$(Sts2DataDir)\sts2.dll')"
34+
Text="Could not find sts2.dll under '$(Sts2DataDir)'. Set Sts2Dir in local.props (see local.props.template), or pass /p:Sts2Dir=... , or set Sts2ApiSignatureRoot for loader builds."/>
35+
</Target>
36+
37+
<ItemGroup>
38+
<Reference Include="sts2" HintPath="$(Sts2DataDir)\sts2.dll" Private="False"/>
39+
<Reference Include="0Harmony" HintPath="$(Sts2DataDir)\0Harmony.dll" Private="False"/>
40+
<Reference Include="Steamworks.NET" HintPath="$(Sts2DataDir)\Steamworks.NET.dll" Private="False"/>
41+
</ItemGroup>
42+
43+
<ItemGroup>
44+
<Compile Include="..\Compat\Sts2HostVersion.cs" Link="Compat\Sts2HostVersion.cs"/>
45+
</ItemGroup>
46+
</Project>

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,15 @@ Windows settings path:
4747

4848
`%appdata%\SlayTheSpire2\steam\<user_id>\mod_data\com.ritsukage.sts2-RitsuLib\settings.json`
4949

50+
## Runtime bundle (multi-API, interim)
51+
52+
End users who want **one mod folder** that picks the correct RitsuLib build for the running game should install the GitHub
53+
asset `STS2-RitsuLib.<version>.bundle.zip` (not the per-compat `*.github.zip` files). Extract it under
54+
`mods/STS2-RitsuLib/`: the root `STS2-RitsuLib.dll` is a small loader; real builds live under `lib/<api-version>/` with
55+
the same assembly name as today. Downstream mods keep declaring `dependencies: ["STS2-RitsuLib"]` and continue to
56+
reference NuGet (`STS2.RitsuLib` / `STS2.RitsuLib.Compat.*`) unchanged. This path is expected to be temporary until
57+
first-party workshop / per-branch installs make separate DLLs straightforward.
58+
5059
## License
5160

5261
MIT

README.zh.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,14 @@ Windows 下设置文件路径:
4444

4545
`%appdata%\SlayTheSpire2\steam\<user_id>\mod_data\com.ritsukage.sts2-RitsuLib\settings.json`
4646

47+
## 运行时 Bundle(多 API,临时方案)
48+
49+
希望**只装一份** RitsuLib、由运行时按游戏选择对应构建的最终用户,请使用 GitHub Release 中的
50+
`STS2-RitsuLib.<version>.bundle.zip`(不要用各 compat 的 `*.github.zip`)。解压到 `mods/STS2-RitsuLib/`:根目录的
51+
`STS2-RitsuLib.dll` 为轻量加载器;各版本实际 DLL 在 `lib/<api-version>/` 下,程序集名与现有一致。下游 mod 的
52+
`dependencies` 仍写 `STS2-RitsuLib`,NuGet 引用方式(`STS2.RitsuLib` / `STS2.RitsuLib.Compat.*`)不变。该形态在创意工坊 /
53+
按游戏版本分支安装成熟后预计可退役。
54+
4755
## 许可证
4856

4957
MIT

STS2-RitsuLib.csproj

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,11 @@
9393
<EmbeddedResource Remove="sts-2-source\**"/>
9494

9595
<Compile Remove="build\**\*.cs"/>
96+
<!-- Loader is a separate assembly; keep it out of Godot.Sdk default globs. -->
97+
<Compile Remove="Loader\**"/>
98+
<None Remove="Loader\**"/>
99+
<Content Remove="Loader\**"/>
100+
<EmbeddedResource Remove="Loader\**"/>
96101
</ItemGroup>
97102
<Target Name="Copy Mod" AfterTargets="Build" Condition="'$(Sts2ApiCompat)' == '$(RitsuLibLatestApiCompat)'">
98103
<Message Text="Copying mod to Slay the Spire 2 mods folder..." Importance="high"/>

STS2-RitsuLib.sln

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
# Visual Studio 2012
33
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "STS2-RitsuLib", "STS2-RitsuLib.csproj", "{61148A8E-0640-46D5-99EC-FD849671762E}"
44
EndProject
5+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "STS2-RitsuLib-Loader", "Loader\STS2-RitsuLib-Loader.csproj", "{C3D4E5F6-A7B8-9012-CDEF-345678901234}"
6+
EndProject
57
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Properties", "Properties", "{615F28BB-DB76-4ED4-AC07-32D7B3420F59}"
68
ProjectSection(SolutionItems) = preProject
79
.\Directory.Build.props = .\Directory.Build.props
@@ -22,5 +24,11 @@ Global
2224
{61148A8E-0640-46D5-99EC-FD849671762E}.ExportDebug|Any CPU.Build.0 = ExportDebug|Any CPU
2325
{61148A8E-0640-46D5-99EC-FD849671762E}.ExportRelease|Any CPU.ActiveCfg = ExportRelease|Any CPU
2426
{61148A8E-0640-46D5-99EC-FD849671762E}.ExportRelease|Any CPU.Build.0 = ExportRelease|Any CPU
27+
{C3D4E5F6-A7B8-9012-CDEF-345678901234}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
28+
{C3D4E5F6-A7B8-9012-CDEF-345678901234}.Debug|Any CPU.Build.0 = Debug|Any CPU
29+
{C3D4E5F6-A7B8-9012-CDEF-345678901234}.ExportDebug|Any CPU.ActiveCfg = Debug|Any CPU
30+
{C3D4E5F6-A7B8-9012-CDEF-345678901234}.ExportDebug|Any CPU.Build.0 = Debug|Any CPU
31+
{C3D4E5F6-A7B8-9012-CDEF-345678901234}.ExportRelease|Any CPU.ActiveCfg = Release|Any CPU
32+
{C3D4E5F6-A7B8-9012-CDEF-345678901234}.ExportRelease|Any CPU.Build.0 = Release|Any CPU
2533
EndGlobalSection
2634
EndGlobal

scripts/ci/gh_publish.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from release_lib.repo_layout import (
1515
ARTIFACTS_GITHUB,
1616
ARTIFACTS_NUGET,
17+
GITHUB_BUNDLE_ZIP_SUFFIX,
1718
GITHUB_PRERELEASE_TAG_NAME,
1819
GITHUB_ZIP_FILENAME_SUFFIX,
1920
NUGET_ORG_V3_INDEX_URL,
@@ -182,11 +183,15 @@ def cmd_dev_prerelease(repo_root: Path) -> None:
182183
+ (f"- Workflow Run: [#{run_id}]({run_url})\n" if run_url else "")
183184
)
184185
zips = sorted((repo_root / ARTIFACTS_GITHUB).glob(f"*{GITHUB_ZIP_FILENAME_SUFFIX}"))
185-
if not zips:
186-
print(f"No *{GITHUB_ZIP_FILENAME_SUFFIX} under {ARTIFACTS_GITHUB}/", file=sys.stderr)
186+
bundle_zips = sorted((repo_root / ARTIFACTS_GITHUB).glob(f"*{GITHUB_BUNDLE_ZIP_SUFFIX}"))
187+
if not zips and not bundle_zips:
188+
print(
189+
f"No *{GITHUB_ZIP_FILENAME_SUFFIX} or *{GITHUB_BUNDLE_ZIP_SUFFIX} under {ARTIFACTS_GITHUB}/",
190+
file=sys.stderr,
191+
)
187192
raise SystemExit(1)
188193
upload_files: list[str] = []
189-
for p in zips:
194+
for p in (*zips, *bundle_zips):
190195
# Keep original artifact name (already includes package/version), append only a short build marker.
191196
short = sha[:8] if sha else "local"
192197
dev_name = p.with_name(f"{p.stem}.sha.{short}{p.suffix}")
@@ -218,12 +223,13 @@ def cmd_tag_release(repo_root: Path, tag: str) -> None:
218223
print("Tag is empty; set GITHUB_REF_NAME or pass --tag.", file=sys.stderr)
219224
raise SystemExit(1)
220225
zips = sorted((repo_root / ARTIFACTS_GITHUB).glob(f"*{GITHUB_ZIP_FILENAME_SUFFIX}"))
226+
bundle_zips = sorted((repo_root / ARTIFACTS_GITHUB).glob(f"*{GITHUB_BUNDLE_ZIP_SUFFIX}"))
221227
nupkgs = sorted(
222228
p
223229
for p in (repo_root / ARTIFACTS_NUGET).glob("*.nupkg")
224230
if not p.name.endswith(SNUPKG_SUFFIX)
225231
)
226-
assets = [str(p) for p in (*zips, *nupkgs)]
232+
assets = [str(p) for p in (*zips, *bundle_zips, *nupkgs)]
227233
if not assets:
228234
print(
229235
f"No release assets under {ARTIFACTS_GITHUB}/ or {ARTIFACTS_NUGET}/.",

scripts/release_cli.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@
1010
from release_lib import git_ops
1111
from release_lib import nuget as nuget_ops
1212
from release_lib import plan_analysis
13+
from release_lib.bundle import compose_bundle_zip
1314
from release_lib.msbuild_eval import get_csproj_property
1415
from release_lib.repo_layout import (
16+
ARTIFACTS_BUNDLE_STAGING,
1517
CONST_CS_NAME,
1618
DEFAULT_GIT_REMOTE,
1719
GIT_DEFAULT_DEV_BRANCH,
@@ -263,6 +265,7 @@ def main(argv: list[str] | None = None) -> int:
263265
if args.version_override:
264266
print(f"[release] pack version override: {args.version_override}", flush=True)
265267
print(f"[release] nuget targets: {', '.join(compat_targets)}", flush=True)
268+
bundle_root = ritsulib / ARTIFACTS_BUNDLE_STAGING
266269
packages, zips = nuget_ops.build_artifacts(
267270
ritsulib,
268271
configuration=args.configuration,
@@ -271,9 +274,20 @@ def main(argv: list[str] | None = None) -> int:
271274
version_override=args.version_override,
272275
sts2_api_signature_root=Path(args.sts2_api_signature_root) if args.sts2_api_signature_root else None,
273276
sts2_dir=Path(args.sts2_dir) if args.sts2_dir else None,
277+
bundle_staging_root=bundle_root,
278+
)
279+
eff_ver = (args.version_override or "").strip() or current_text
280+
bundle_zip = compose_bundle_zip(
281+
ritsulib,
282+
configuration=args.configuration,
283+
effective_version=eff_ver,
284+
sts2_api_signature_root=Path(args.sts2_api_signature_root) if args.sts2_api_signature_root else None,
285+
sts2_dir=Path(args.sts2_dir) if args.sts2_dir else None,
286+
bundle_staging_root=bundle_root,
274287
)
275288
print(f"[release] Artifacts packages: {', '.join(pkg.name for pkg in packages)}")
276289
print(f"[release] Artifacts zips: {', '.join(zip_path.name for zip_path in zips)}")
290+
print(f"[release] Bundle zip: {bundle_zip.name}")
277291
print("[release] done (artifacts-only).")
278292
return 0
279293

@@ -466,6 +480,7 @@ def main(argv: list[str] | None = None) -> int:
466480
subprocess.run(tag_push, cwd=repo, check=True)
467481

468482
if args.push_nuget:
483+
bundle_root = ritsulib / ARTIFACTS_BUNDLE_STAGING
469484
published, github_zips = nuget_ops.publish_nugets(
470485
ritsulib,
471486
configuration=args.configuration,
@@ -476,9 +491,20 @@ def main(argv: list[str] | None = None) -> int:
476491
version_override=args.version_override,
477492
sts2_api_signature_root=Path(args.sts2_api_signature_root) if args.sts2_api_signature_root else None,
478493
sts2_dir=Path(args.sts2_dir) if args.sts2_dir else None,
494+
bundle_staging_root=bundle_root,
495+
)
496+
eff_ver = (args.version_override or "").strip() or str(next_text)
497+
bundle_zip = compose_bundle_zip(
498+
ritsulib,
499+
configuration=args.configuration,
500+
effective_version=eff_ver,
501+
sts2_api_signature_root=Path(args.sts2_api_signature_root) if args.sts2_api_signature_root else None,
502+
sts2_dir=Path(args.sts2_dir) if args.sts2_dir else None,
503+
bundle_staging_root=bundle_root,
479504
)
480505
print(f"[release] NuGet published: {', '.join(pkg.name for pkg in published)}")
481506
print(f"[release] GitHub zips: {', '.join(zip_path.name for zip_path in github_zips)}")
507+
print(f"[release] Bundle zip: {bundle_zip.name}")
482508
else:
483509
print(
484510
"[release] Skipping local NuGet push (default); packages are published by the tag release workflow.",

0 commit comments

Comments
 (0)