Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Abstracts/CustomCharacterModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,15 @@ public static void Register(CustomCharacterModel character)

CustomCharacters.Add(character);
}

/// <summary>
/// Remove all custom characters belonging to the given assembly.
/// Called during hot reload cleanup before re-registration from the new assembly.
/// </summary>
internal static void RemoveByAssembly(System.Reflection.Assembly asm)
{
CustomCharacters.RemoveAll(c => c.GetType().Assembly == asm);
}
}


Expand Down
14 changes: 14 additions & 0 deletions Abstracts/CustomOrbModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,17 @@ public CustomOrbModel()
{
RegisteredOrbs.Add(this);
}

/// <summary>
/// Remove all orbs belonging to the given assembly and clear the random pool
/// cache so it gets rebuilt on next access. Called during hot reload cleanup.
/// </summary>
internal static void RemoveByAssembly(System.Reflection.Assembly asm)
{
RegisteredOrbs.RemoveAll(o => o.GetType().Assembly == asm);
// Force the random pool cache to rebuild with the new set of orbs
CustomOrbRandomPool.ClearCache();
}

/// <summary>
/// Override this to define localization directly in your class.
Expand Down Expand Up @@ -92,6 +103,9 @@ class CustomOrbRandomPool
{
private static List<OrbModel>? _eligibleCache;

/// <summary>Null the cache so it gets rebuilt from the current RegisteredOrbs list.</summary>
internal static void ClearCache() => _eligibleCache = null;

static void Postfix(Rng rng, ref OrbModel __result)
{
_eligibleCache ??= CustomOrbModel.RegisteredOrbs
Expand Down
16 changes: 15 additions & 1 deletion BaseLib.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,12 @@
</Reference>
</ItemGroup>

<ItemGroup>
<!-- Exclude the test mod from BaseLib's own build — it has its own csproj -->
<Compile Remove="test_hotreload_mod\**" />
<None Remove="test_hotreload_mod\**" />
</ItemGroup>

<ItemGroup>
<None Include="$(MSBuildProjectName)\**" />
<None Include="LICENSE.txt" />
Expand All @@ -112,10 +118,18 @@

<ItemGroup>
<!-- For including in .nupkg for template to auto export to mods folder -->
<Content Condition="Exists('build\BaseLib.props')" Include="build\BaseLib.props" PackagePath="/Content/" />
<Content Condition="Exists('build\BaseLib.pck')" Include="build\$(MSBuildProjectName).pck" PackagePath="/Content/" />
<Content Include="$(MSBuildProjectName).json" PackagePath="/Content/" />
</ItemGroup>

<ItemGroup>
<!--
Package BaseLib.props at /build/ so NuGet auto-imports it into consuming projects.
This gives mod projects the hot reload assembly stamping and CopyToModsFolder targets
for free just by referencing the BaseLib NuGet package.
-->
<None Condition="Exists('build\BaseLib.props')" Include="build\BaseLib.props" Pack="true" PackagePath="build/" />
</ItemGroup>

<Target Name="UpdateManifestVersion" BeforeTargets="BeforeBuild">
<PropertyGroup>
Expand Down
4 changes: 4 additions & 0 deletions BaseLibMain.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Reflection;
using System.Runtime.InteropServices;
using BaseLib.Config;
using BaseLib.HotReload;
using BaseLib.Patches.Content;
using BaseLib.Utils.NodeFactories;
using HarmonyLib;
Expand Down Expand Up @@ -38,6 +39,9 @@ public static void Initialize()
TheBigPatchToCardPileCmdAdd.Patch(harmony);

harmony.PatchAll();

NodeFactory.RunSelfTests();
HotReloadEngine.Init();
}

//Hopefully temporary fix for linux
Expand Down
188 changes: 188 additions & 0 deletions Commands/HotReloadCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
using BaseLib.HotReload;
using MegaCrit.Sts2.Core.DevConsole;
using MegaCrit.Sts2.Core.DevConsole.ConsoleCommands;
using MegaCrit.Sts2.Core.Entities.Players;

namespace BaseLib.Commands;

/// <summary>
/// Console command to hot reload a mod assembly at runtime.
///
/// Usage:
/// hotreload MyMod — reload by mod ID (auto-detects tier from folder contents)
/// hotreload E:/mods/MyMod/MyMod.dll — reload a specific DLL path
/// hotreload MyMod 3 — reload with explicit tier 3 (includes PCK remount)
/// hotreload MyMod 1 — reload patches only (no entity refresh)
/// </summary>
public class HotReloadCommand : AbstractConsoleCmd
{
public override string CmdName => "hotreload";
public override string Args => "<dll_path_or_mod_id> [tier]";
public override string Description => "Hot reload a mod assembly (tier: 1=patches, 2=entities, 3=+PCK, 0=auto)";
public override bool IsNetworked => false;

public override CmdResult Process(Player? issuingPlayer, string[] args)
{
// tier=0 means auto-detect (has .pck → 3, otherwise → 2)
int tier = 0;
string? target = null;

// Parse arguments — numbers 1-3 are tier, everything else is the target
foreach (var arg in args)
{
if (int.TryParse(arg, out var t) && t is >= 1 and <= 3)
tier = t;
else if (!string.IsNullOrWhiteSpace(arg))
target = arg;
}

if (target == null)
return new CmdResult(false, "Usage: hotreload <dll_path_or_mod_id> [tier]\nUse hotreload_list to see available mods.");

HotReloadResult result;

if (target.Contains('/') || target.Contains('\\') || target.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
{
// Looks like a file path — use tier 2 as default if not specified
result = HotReloadEngine.Reload(target, tier == 0 ? 2 : tier);
}
else
{
// Looks like a mod ID — auto-detect tier from directory contents
result = HotReloadEngine.ReloadByModId(target, tier);
}

return new CmdResult(result.Success, result.Summary);
}
}

/// <summary>
/// Console command to list mods available for hot reload.
///
/// Usage:
/// hotreload_list — show all mod folders with their latest DLL timestamps
/// </summary>
public class HotReloadListCommand : AbstractConsoleCmd
{
public override string CmdName => "hotreload_list";
public override string Args => "";
public override string Description => "List mods available for hot reload";
public override bool IsNetworked => false;

public override CmdResult Process(Player? issuingPlayer, string[] args)
{
var modsDir = FindModsDirectory();
if (modsDir == null)
return new CmdResult(false, "Could not find game mods directory.");

var lines = new List<string> { $"Mods in {modsDir}:" };

foreach (var modDir in Directory.GetDirectories(modsDir).OrderBy(d => d))
{
var modName = Path.GetFileName(modDir);
var dlls = Directory.GetFiles(modDir, "*.dll");
if (dlls.Length == 0) continue;

// Find the most recently modified DLL and how long ago it was written
var latest = dlls.OrderByDescending(File.GetLastWriteTimeUtc).First();
var age = DateTime.UtcNow - File.GetLastWriteTimeUtc(latest);
var ageStr = age.TotalMinutes < 1 ? "just now"
: age.TotalMinutes < 60 ? $"{age.TotalMinutes:0}m ago"
: age.TotalHours < 24 ? $"{age.TotalHours:0}h ago"
: $"{age.TotalDays:0}d ago";

var hasPck = Directory.GetFiles(modDir, "*.pck").Length > 0;
var pckTag = hasPck ? " [PCK]" : "";

lines.Add($" {modName} — {Path.GetFileName(latest)} ({ageStr}){pckTag}");
}

if (lines.Count == 1)
lines.Add(" (no mods with DLLs found)");

return new CmdResult(true, string.Join("\n", lines));
}

private static string? FindModsDirectory()
{
var executable = System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName;
if (executable == null) return null;
var gameDir = Path.GetDirectoryName(executable);
if (gameDir == null) return null;
var modsDir = Path.Combine(gameDir, "mods");
return Directory.Exists(modsDir) ? modsDir : null;
}
}

/// <summary>
/// Console command to show hot reload status and history.
///
/// Usage:
/// hotreload_status — show last reload result and watcher state
/// </summary>
public class HotReloadStatusCommand : AbstractConsoleCmd
{
public override string CmdName => "hotreload_status";
public override string Args => "";
public override string Description => "Show hot reload status and recent history";
public override bool IsNetworked => false;

public override CmdResult Process(Player? issuingPlayer, string[] args)
{
var progress = HotReloadEngine.CurrentProgress;
var history = HotReloadEngine.ReloadHistory;
var watcher = HotReloadEngine.FileWatcher;

var lines = new List<string>();

if (!string.IsNullOrEmpty(progress))
lines.Add($"In progress: {progress}");
else
lines.Add("No reload in progress");

lines.Add($"Watcher: {(watcher?.IsWatching == true ? "active" : "inactive")}");
lines.Add($"Reload history: {history.Count} entries");

if (history.Count > 0)
{
var last = history[^1];
lines.Add($"Last: {last.Summary}");
}

return new CmdResult(true, string.Join("\n", lines));
}
}

/// <summary>
/// Console command to run the hot reload integration test suite.
/// These tests exercise the full pipeline with the live game state.
///
/// Usage:
/// hotreload_test — run all integration tests and report results
/// </summary>
public class HotReloadTestCommand : AbstractConsoleCmd
{
public override string CmdName => "hotreload_test";
public override string Args => "";
public override string Description => "Run hot reload integration tests against live game state";
public override bool IsNetworked => false;

public override CmdResult Process(Player? issuingPlayer, string[] args)
{
var (passed, failed, failures) = HotReloadSelfTests.RunIntegrationTests();

var lines = new List<string>();
if (failed == 0)
{
lines.Add($"All {passed} integration tests passed!");
}
else
{
lines.Add($"{passed} passed, {failed} FAILED:");
foreach (var f in failures)
lines.Add($" FAIL: {f}");
}

return new CmdResult(failed == 0, string.Join("\n", lines));
}
}
3 changes: 3 additions & 0 deletions Config/BaseLibConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,7 @@ internal class BaseLibConfig : SimpleModConfig
[ConfigHideInUI] public static int LogLastSizeY { get; set; } = 0;
[ConfigHideInUI] public static int LogLastPosX { get; set; } = 0;
[ConfigHideInUI] public static int LogLastPosY { get; set; } = 0;

[ConfigSection("HotReloadSection")]
public static bool EnableFileWatcher { get; set; } = false;
}
35 changes: 35 additions & 0 deletions HotReload/AssemblyStamper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using System.Text.RegularExpressions;

namespace BaseLib.HotReload;

/// <summary>
/// Hot reload builds stamp each assembly with a unique timestamp suffix like _hr143052789.
/// These helpers strip that suffix to recover the canonical mod name, and detect whether
/// a given DLL was produced by a hot reload build.
/// </summary>
internal static class AssemblyStamper
{
private static readonly Regex HotReloadSuffixRegex = new(@"_hr\d{6,9}$", RegexOptions.Compiled | RegexOptions.IgnoreCase);

/// <summary>
/// Strip the _hrNNNNNNNNN suffix from an assembly name or file path to get the canonical mod key.
/// E.g. "MyMod_hr143052789" → "MyMod", "E:/mods/MyMod/MyMod_hr143052789.dll" → "MyMod"
/// </summary>
public static string NormalizeModKey(string? assemblyNameOrPath)
{
if (string.IsNullOrWhiteSpace(assemblyNameOrPath))
return "";
var fileOrAssemblyName = Path.GetFileNameWithoutExtension(assemblyNameOrPath);
return HotReloadSuffixRegex.Replace(fileOrAssemblyName, "");
}

/// <summary>
/// Returns true if this DLL was produced by a hot reload build (has the _hrNNN suffix).
/// Used by the file watcher to ignore dependency DLLs and manifests.
/// </summary>
public static bool IsHotReloadStamped(string path)
{
var name = Path.GetFileNameWithoutExtension(path);
return HotReloadSuffixRegex.IsMatch(name);
}
}
Loading