Compare commits

..

10 Commits

Author SHA1 Message Date
Matt 987fedd7e4 pack: add Almost Unified 1.4.2 + bump to 0.12.0
Preemptive insurance against duplicate-item proliferation when more
metals-producing mods are added (e.g. when Power Grid lands, or any
future Mekanism/Thermal/etc. addition). Today's pack only has TFMG as
the metals provider so AU is largely a no-op now, but ships with sane
default tag preferences that activate automatically as duplicates appear.

Tiny mod (~290 KB jar), client-optional/server-required per Modrinth.
2026-05-22 20:34:32 +00:00
Matt 8d0c05bd8a Merge pull request 'Add Simple Voice Chat 2.6.17' (#3) from feature/add-simple-voice-chat into main 2026-05-22 20:23:14 +00:00
Matt 95966d2d14 pack: add Simple Voice Chat 2.6.17 + bump to 0.11.0
Adds proximity voice chat to the modpack. Server uses UDP/24454 by default
(firewall rule added separately).

Modrinth lists both sides as "optional" -- players without it can still
join, just no voice. Tagged "both" in the lockfile so server + launcher
both install it.
2026-05-22 20:23:07 +00:00
Matt 839d93f242 Add explicit client/server side field to manifest (#2) 2026-05-22 15:15:03 +00:00
Matt db4534e427 Add local deploy mode to Deploy-Brass.ps1 (#1) 2026-05-22 15:14:44 +00:00
Matt 4ee4a2bb43 scripts: make Build-Pack and Deploy-Brass cross-platform
Two small fixes that surface when running the deploy pipeline on a
non-Windows host (we now do this on glados via the Telegram bridge):

- Build-Pack.ps1: fall back to launcher/ModpackLauncher.csproj <Version>
  when Get-Item.VersionInfo.FileVersion returns empty. Linux PowerShell
  can't parse PE FileVersion from a Windows .exe, so the previous code
  threw "set <Version> in ModpackLauncher.csproj and republish" -- but
  the csproj <Version> *was* set, just not readable from a foreign PE.
  Appends ".0" so the embedded value still matches the 4-part form the
  Windows compile bakes in.

- Deploy-Brass.ps1: prefer rsync where available, fall back to robocopy
  otherwise. robocopy is Windows-only and bails on Linux/macOS hosts.
2026-05-22 14:23:09 +00:00
Matt 4594cb9c00 Add explicit client/server side field to manifest
Bake each file's "side" (client / server / both) into manifest.json at
build time so both the launcher and the server filter deterministically
and offline. This replaces the launcher's previous "download everything"
default and lets the server short-circuit its runtime Modrinth lookup
when a file already has an authoritative tag.

Schema:
- ManifestFile.Side: "client" | "server" | "both" (null = "both" for
  backward compat with manifests pre-dating this field)
- Files marked "both" omit the field entirely to keep the JSON tight

Code changes:
- launcher Manifest.cs + ManifestSyncService: filter out "server" files
  in both the prune+download path and FindMissingFiles
- server Manifest.cs + ManifestSync: drop "client" files outright; only
  fall back to the existing runtime Modrinth lookup for files with no
  explicit side (legacy/un-tagged mods stay protected)
- server LockedMod: side field propagates into manifest at build time
- scripts/Build-Pack.ps1: propagate side from lockfile to manifest
- scripts/Bootstrap-Sides.ps1: one-off populator; queries Modrinth's
  client_side/server_side per project, conservatively marks restricted
  only when one side is "unsupported", leaves ambiguous cases as "both"
- pack/pack.lock.json: bootstrap-populated sides (3 client, 5 server,
  rest both); CurseForge mods default to "both" pending manual review;
  version bumped 0.9.3 -> 0.10.0 since clients must re-sync

Compatibility:
- Old launcher + new manifest: ignores unknown "side", downloads all
- New launcher + old manifest: side null -> "both", installs all
- Old server + new manifest: same -- runtime Modrinth lookup still works
- New server + old manifest: side null -> runtime lookup, same behaviour
2026-05-22 14:17:43 +00:00
Matt f280e107f3 Add 'local' deploy mode to Deploy-Brass.ps1
When ServerSshHost = 'local' the server-binary stage skips scp/ssh and
does an in-place Copy-Item + chmod + Move-Item on the local filesystem.
Pre-flight pgrep also runs locally. Allows running the deploy script on
the server itself (glados in our case) without setting up ssh-to-self.

Other deploy modes (remote scp/ssh) unchanged.
2026-05-22 14:08:02 +00:00
Matt Sijbers e0b9278da4 launcher: bump to 0.4.8 (test artifact for the self-update flow shipped in 0.4.7) 2026-05-20 22:09:56 +01:00
Matt Sijbers f311429bc2 launcher: in-place self-update via download + swap + restart
Adds Services/SelfUpdateService.cs implementing the Chrome-style pattern:
  1. Download new exe to "<current>.new"
  2. Rename running exe to "<current>.old" (NTFS allows MoveFile on a
     running exe -- it resolves processes by handle, not by path)
  3. Rename ".new" to canonical path
  4. Spawn the new exe with our argv
  5. Environment.Exit(0)
  6. Next start of the new exe runs CleanupAfterUpdate() in App.OFIC()
     which deletes the leftover ".old"

UI: the existing "Download" banner button is now "Install update". Click
runs the self-update flow with a percent/MB progress label; any failure
(network, AV write-block, dir not writable) falls back to opening the
URL in the browser so users always have a path forward.

Bumps to 0.4.7. Also fixes a stale fallback URL that pointed at
sijbers.uk/pack/... -- now correctly points at bns.sijbers.uk/launcher/.
2026-05-20 22:07:03 +01:00
14 changed files with 836 additions and 384 deletions
+6
View File
@@ -1,6 +1,7 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using ModpackLauncher.Services;
namespace ModpackLauncher;
@@ -13,6 +14,11 @@ public partial class App : Application
public override void OnFrameworkInitializationCompleted()
{
// Clean up any .old launcher left behind by a self-update on a prior run.
// Best-effort: if the previous process hasn't fully released the handle yet
// we just retry on the next startup.
SelfUpdateService.CleanupAfterUpdate();
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new MainWindow();
+1 -1
View File
@@ -259,7 +259,7 @@
Foreground="#E8DFC8" FontSize="13" TextWrapping="Wrap"
Text="A newer launcher is available." />
<Button Grid.Column="2" Name="UpdateBannerDownloadButton"
Classes="secondary" Content="Download"
Classes="secondary" Content="Install update"
Click="OnUpdateBannerDownloadClick" />
</Grid>
</Border>
+41 -6
View File
@@ -128,22 +128,57 @@ public partial class MainWindow : Window
if (current >= advertised) return;
UpdateBannerText.Text = $"A newer launcher (v{advertised}) is available -- you're on v{current.Major}.{current.Minor}.{current.Build}.";
UpdateBannerDownloadButton.Tag = manifest.LauncherUrl ?? "https://sijbers.uk/pack/BrassAndSigil-Launcher.exe";
UpdateBannerDownloadButton.Tag = manifest.LauncherUrl ?? "https://bns.sijbers.uk/launcher/BrassAndSigil-Launcher.exe";
UpdateBanner.IsVisible = true;
AppendLog($"[update] Newer launcher available: v{advertised} (running v{current})");
}
private void OnUpdateBannerDownloadClick(object? sender, RoutedEventArgs e)
private async void OnUpdateBannerDownloadClick(object? sender, RoutedEventArgs e)
{
var url = UpdateBannerDownloadButton.Tag as string
?? "https://sijbers.uk/pack/BrassAndSigil-Launcher.exe";
var url = UpdateBannerDownloadButton.Tag as string;
if (string.IsNullOrEmpty(url))
{
AppendLog("[update] No download URL configured in manifest.");
return;
}
// First click: try in-place self-update. On failure (network blip, write-protected
// dir, antivirus blocking), fall back to opening the browser so the user can still
// grab the new exe manually.
UpdateBannerDownloadButton.IsEnabled = false;
var originalLabel = UpdateBannerDownloadButton.Content as string ?? "Install update";
UpdateBannerDownloadButton.Content = "Updating...";
try
{
Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true });
var svc = new ModpackLauncher.Services.SelfUpdateService();
var progress = new Progress<(long bytes, long? total)>(p =>
{
var mb = p.bytes / 1024.0 / 1024.0;
UpdateBannerDownloadButton.Content = p.total.HasValue
? $"{(double)p.bytes * 100 / p.total.Value:F0}%"
: $"{mb:F1} MB";
});
await svc.DownloadAndInstallAsync(url, progress, AppendLog);
// If the method returns, it means it didn't exit -- that shouldn't normally
// happen, but reset the UI just in case.
UpdateBannerDownloadButton.Content = originalLabel;
UpdateBannerDownloadButton.IsEnabled = true;
}
catch (Exception ex)
{
AppendLog($"[update] Couldn't open browser: {ex.Message}");
AppendLog($"[update] Auto-update failed: {ex.Message}");
AppendLog("[update] Opening browser as fallback...");
try
{
Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true });
}
catch (Exception ex2)
{
AppendLog($"[update] Browser fallback also failed: {ex2.Message}");
}
UpdateBannerDownloadButton.Content = originalLabel;
UpdateBannerDownloadButton.IsEnabled = true;
}
}
+9
View File
@@ -95,6 +95,15 @@ public sealed class ManifestFile
[JsonPropertyName("size")]
public long? Size { get; set; }
/// <summary>
/// "both" (default), "client", or "server". The launcher skips files
/// marked "server"; the server-tool skips files marked "client".
/// Null/missing/unknown = treated as "both" so manifests pre-dating
/// this field stay fully compatible.
/// </summary>
[JsonPropertyName("side")]
public string? Side { get; set; }
}
public sealed class PackVersionRecord
+1 -1
View File
@@ -10,7 +10,7 @@
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<RootNamespace>ModpackLauncher</RootNamespace>
<AssemblyName>ModpackLauncher</AssemblyName>
<Version>0.4.6</Version>
<Version>0.4.8</Version>
<ApplicationIcon Condition="Exists('Assets\icon.ico')">Assets\icon.ico</ApplicationIcon>
<!-- Single-file self-contained publish defaults (Windows-only now due to WebView2) -->
+22
View File
@@ -66,6 +66,7 @@ public sealed class ManifestSyncService
var missing = new System.Collections.Generic.List<ManifestFile>();
foreach (var file in manifest.Files)
{
if (IsServerOnly(file)) continue; // never expected on disk client-side
var dest = Path.Combine(installDir, file.Path);
if (!File.Exists(dest)) missing.Add(file);
}
@@ -113,6 +114,19 @@ public sealed class ManifestSyncService
$"Pack: {manifest.Name ?? "modpack"} v{manifest.Version ?? "?"} (local: {local?.Version ?? "none"})"
));
// Drop server-only files before computing wanted/download sets. Doing
// it once here means the rest of the sync (prune + download) doesn't
// need to know about sides. Unknown/missing/'both' = include on client.
var serverOnlyCount = manifest.Files.Count(f => IsServerOnly(f));
if (serverOnlyCount > 0)
{
manifest.Files = manifest.Files.Where(f => !IsServerOnly(f)).ToList();
progress.Report(new ProgressReport(
ProgressKind.Log,
$"Skipping {serverOnlyCount} server-only file(s) declared in manifest."
));
}
var wantedPaths = new HashSet<string>(
manifest.Files.Select(f => NormalizePath(f.Path)),
StringComparer.OrdinalIgnoreCase
@@ -249,4 +263,12 @@ public sealed class ManifestSyncService
}
private static string NormalizePath(string p) => p.Replace('\\', '/').TrimStart('/');
/// <summary>
/// True if this manifest file is tagged as server-only and therefore
/// should not be installed on the client. Unknown/null/'both' = false
/// (always include) so manifests pre-dating the field still install.
/// </summary>
private static bool IsServerOnly(ManifestFile f)
=> string.Equals(f.Side, "server", StringComparison.OrdinalIgnoreCase);
}
+138
View File
@@ -0,0 +1,138 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace ModpackLauncher.Services;
/// <summary>
/// In-place launcher self-update. Pattern matches Chrome/VS Code/Discord:
/// 1. Download new exe to "<current>.new"
/// 2. Rename running exe to "<current>.old" (Windows allows MoveFile on a running exe)
/// 3. Rename ".new" to the canonical name
/// 4. Process.Start the new exe with our args
/// 5. Exit -- the OS releases the .old file once the old process is gone
/// 6. Next launcher start deletes any leftover .old via CleanupAfterUpdate()
///
/// Fails loudly (throws) on any step. Caller falls back to browser-open download.
/// </summary>
public sealed class SelfUpdateService
{
private static readonly HttpClient _http = new()
{
Timeout = TimeSpan.FromMinutes(5),
DefaultRequestVersion = new Version(2, 0),
};
public static string CurrentExePath =>
Environment.ProcessPath
?? throw new InvalidOperationException("Environment.ProcessPath was null (self-update needs a real exe path).");
public static string OldExePath => CurrentExePath + ".old";
public static string NewExePath => CurrentExePath + ".new";
/// <summary>
/// Delete any leftover .old file from a prior self-update. Safe to call on every startup;
/// quietly retries next start if the file is still locked (rare timing edge case).
/// </summary>
public static void CleanupAfterUpdate(Action<string>? log = null)
{
var old = OldExePath;
if (!File.Exists(old)) return;
try
{
File.Delete(old);
log?.Invoke($"[update] Removed previous launcher backup: {Path.GetFileName(old)}");
}
catch (Exception ex)
{
// The old process may still be releasing the file handle. Not fatal --
// we'll retry next start. Worst case the .old file sits there harmlessly.
log?.Invoke($"[update] Couldn't remove {Path.GetFileName(old)} yet ({ex.GetType().Name}); will retry next start.");
}
}
/// <summary>
/// Download the new launcher, swap it in, restart with the same command line, and exit.
/// Returns only on failure (i.e. throws) -- on success the current process exits.
/// </summary>
public async Task DownloadAndInstallAsync(
string downloadUrl,
IProgress<(long bytesReceived, long? totalBytes)>? progress = null,
Action<string>? log = null,
CancellationToken ct = default)
{
var curPath = CurrentExePath;
var newPath = NewExePath;
var oldPath = OldExePath;
log?.Invoke($"[update] Downloading {downloadUrl}");
// 1. Download to .new, replacing any stale partial from a failed prior attempt
if (File.Exists(newPath)) File.Delete(newPath);
using (var resp = await _http.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead, ct))
{
resp.EnsureSuccessStatusCode();
var total = resp.Content.Headers.ContentLength;
await using var src = await resp.Content.ReadAsStreamAsync(ct);
await using var dst = new FileStream(newPath, FileMode.CreateNew, FileAccess.Write, FileShare.None);
var buffer = new byte[81920];
long received = 0;
int read;
while ((read = await src.ReadAsync(buffer, ct)) > 0)
{
await dst.WriteAsync(buffer.AsMemory(0, read), ct);
received += read;
progress?.Report((received, total));
}
log?.Invoke($"[update] Downloaded {received:N0} bytes");
}
// Sanity: refuse to swap in something laughably small (a captive portal
// intercepted us, or the server returned an error body as 200 OK).
var fi = new FileInfo(newPath);
if (fi.Length < 1_000_000)
{
File.Delete(newPath);
throw new InvalidDataException($"Downloaded launcher is only {fi.Length:N0} bytes -- almost certainly not the real binary.");
}
// 2. Move running exe to .old (this works while we're executing — NTFS resolves
// running processes by handle, not by path).
if (File.Exists(oldPath)) File.Delete(oldPath);
File.Move(curPath, oldPath);
log?.Invoke($"[update] Backed up current launcher to {Path.GetFileName(oldPath)}");
// 3. Move new exe into the canonical slot
File.Move(newPath, curPath);
log?.Invoke($"[update] New launcher staged at {Path.GetFileName(curPath)}");
// 4. Build the restart command. Pass through any args the current process was
// launched with (e.g. URI handler args from a future deep-link integration),
// skipping argv[0] which is the exe itself.
var args = Environment.GetCommandLineArgs();
var psi = new ProcessStartInfo
{
FileName = curPath,
UseShellExecute = false,
WorkingDirectory = Path.GetDirectoryName(curPath) ?? Environment.CurrentDirectory,
};
for (int i = 1; i < args.Length; i++) psi.ArgumentList.Add(args[i]);
log?.Invoke("[update] Restarting with the new launcher...");
Process.Start(psi);
// Tiny grace period so the spawn has actually entered the kernel before we
// pull our pid out from under it. 100 ms is more than enough.
await Task.Delay(100, CancellationToken.None);
// Hard exit. We don't go through Avalonia shutdown -- Application.Lifetime.Exit()
// can hang on window close handlers, and we DO want to release our exe handle
// promptly so the new process's CleanupAfterUpdate() can delete our .old file.
Environment.Exit(0);
}
}
+402 -347
View File
@@ -1,349 +1,404 @@
{
"$schema": "Brass-and-Sigil pack.lock.json - generated, do not edit by hand unless you know what you are doing",
"name": "Brass and Sigil",
"version": "0.9.3",
"minecraft": "1.21.1",
"loader": {
"type": "neoforge",
"version": "21.1.228"
},
"lockedAt": "2026-05-04T14:22:58.7131203+01:00",
"mods": [
{
"source": "modrinth",
"slug": "create",
"versionId": "UjX6dr61",
"version": "6.0.10+mc1.21.1",
"path": "mods/create-1.21.1-6.0.10.jar",
"url": "https://cdn.modrinth.com/data/LNytGWDc/versions/UjX6dr61/create-1.21.1-6.0.10.jar",
"sha1": "0e97e49837bed766e6f28a4c95b04885d6acc353",
"size": 19123767
},
{
"source": "modrinth",
"slug": "create-aeronautics",
"versionId": "YhZLrAFC",
"version": "1.2.1+mc1.21.1",
"path": "mods/create-aeronautics-bundled-1.21.1-1.2.1.jar",
"url": "https://cdn.modrinth.com/data/oWaK0Q19/versions/YhZLrAFC/create-aeronautics-bundled-1.21.1-1.2.1.jar",
"sha1": "fdf1ae69e8b6437e0196b3a35dd2325aa904aba9",
"size": 33030286
},
{
"source": "modrinth",
"slug": "sable",
"versionId": "3FMsUjO4",
"version": "1.2.2+mc1.21.1",
"path": "mods/sable-neoforge-1.21.1-1.2.2.jar",
"url": "https://cdn.modrinth.com/data/T9PomCSv/versions/3FMsUjO4/sable-neoforge-1.21.1-1.2.2.jar",
"sha1": "c5ecd3fcf60a31d84112c708abe29e341b2d1b73",
"size": 12719293
},
{
"source": "modrinth",
"slug": "create-big-cannons",
"versionId": "bsGaXKEd",
"version": "5.11.3",
"path": "mods/createbigcannons-5.11.3+mc.1.21.1.jar",
"url": "https://cdn.modrinth.com/data/GWp4jCJj/versions/bsGaXKEd/createbigcannons-5.11.3%2Bmc.1.21.1.jar",
"sha1": "8b61fa850e260bdeb5d360576123f98c260afa50",
"size": 3715787
},
{
"source": "modrinth",
"slug": "create-tfmg",
"versionId": "uDi14nbt",
"version": "1.2.0",
"path": "mods/tfmg-1.2.0.jar",
"url": "https://cdn.modrinth.com/data/USgVjXsk/versions/uDi14nbt/tfmg-1.2.0.jar",
"sha1": "b520f3687f60a69eb265ff5b9a16759b9e124103",
"size": 4924243
},
{
"source": "modrinth",
"slug": "distanthorizons",
"versionId": "KkaaQtTD",
"version": "3.0.2-b-1.21.1",
"path": "mods/DistantHorizons-3.0.2-b-1.21.1-fabric-neoforge.jar",
"url": "https://cdn.modrinth.com/data/uCdwusMi/versions/KkaaQtTD/DistantHorizons-3.0.2-b-1.21.1-fabric-neoforge.jar",
"sha1": "1ff0a8920e52add541471f7b32d0d389997145ba",
"size": 30019727
},
{
"source": "modrinth",
"slug": "sodium",
"versionId": "Pb3OXVqC",
"version": "mc1.21.1-0.6.13-neoforge",
"path": "mods/sodium-neoforge-0.6.13+mc1.21.1.jar",
"url": "https://cdn.modrinth.com/data/AANobbMI/versions/Pb3OXVqC/sodium-neoforge-0.6.13%2Bmc1.21.1.jar",
"sha1": "38af70fa4dc4b2aaac636e92fdba3bedd5a025e1",
"size": 1162994
},
{
"source": "modrinth",
"slug": "iris",
"versionId": "t3ruzodq",
"version": "1.8.12+1.21.1-neoforge",
"path": "mods/iris-neoforge-1.8.12+mc1.21.1.jar",
"url": "https://cdn.modrinth.com/data/YL57xq9U/versions/t3ruzodq/iris-neoforge-1.8.12%2Bmc1.21.1.jar",
"sha1": "a3e6355915c7d3b2bc392724795113e51d289378",
"size": 2438548
},
{
"source": "modrinth",
"slug": "modernfix",
"versionId": "6U8JVjdw",
"version": "5.27.4+mc1.21.1",
"path": "mods/modernfix-neoforge-5.27.4+mc1.21.1.jar",
"url": "https://cdn.modrinth.com/data/nmDcB62a/versions/6U8JVjdw/modernfix-neoforge-5.27.4%2Bmc1.21.1.jar",
"sha1": "2f39363f0d6d5a5ccc2a9e0f50ad3385611c3cb7",
"size": 562051
},
{
"source": "modrinth",
"slug": "ferrite-core",
"versionId": "x7kQWVju",
"version": "7.0.3-neoforge",
"path": "mods/ferritecore-7.0.3-neoforge.jar",
"url": "https://cdn.modrinth.com/data/uXXizFIs/versions/x7kQWVju/ferritecore-7.0.3-neoforge.jar",
"sha1": "9563692efb708b6b568df27a01ec52f6311928ef",
"size": 121559
},
{
"source": "modrinth",
"slug": "architectury-api",
"versionId": "ZxYGwlk0",
"version": "13.0.8+neoforge",
"path": "mods/architectury-13.0.8-neoforge.jar",
"url": "https://cdn.modrinth.com/data/lhGA9TYQ/versions/ZxYGwlk0/architectury-13.0.8-neoforge.jar",
"sha1": "6ca11d3cc136bf69bb8f4d56982481eb85b5100b",
"size": 584004
},
{
"source": "modrinth",
"slug": "rhino",
"versionId": "ZdLtebKH",
"version": "2101.2.7-build.81+Rhino-1.21",
"path": "mods/rhino-2101.2.7-build.81.jar",
"url": "https://cdn.modrinth.com/data/sk9knFPE/versions/ZdLtebKH/rhino-2101.2.7-build.81.jar",
"sha1": "480235a9f7749f68ce6fec3b9c3cac3428b92a4a",
"size": 882033
},
{
"source": "modrinth",
"slug": "rpl",
"versionId": "hZ6B2Z0x",
"version": "2.1.2",
"path": "mods/ritchiesprojectilelib-2.1.2+mc.1.21.1-neoforge.jar",
"url": "https://cdn.modrinth.com/data/B3pb093D/versions/hZ6B2Z0x/ritchiesprojectilelib-2.1.2%2Bmc.1.21.1-neoforge.jar",
"sha1": "ec2e4996f8bee8714173e603e379fef8a6901765",
"size": 76369
},
{
"source": "modrinth",
"slug": "kubejs",
"versionId": "Fe9CjPws",
"version": "2101.7.2-build.363",
"path": "mods/kubejs-neoforge-2101.7.2-build.363.jar",
"url": "https://cdn.modrinth.com/data/umyGl7zF/versions/Fe9CjPws/kubejs-neoforge-2101.7.2-build.363.jar",
"sha1": "d4e88254e8c26687d4c6aeb4dfa9c2ad70f260a2",
"size": 2270442
},
{
"source": "modrinth",
"slug": "jei",
"versionId": "YAcQ6elZ",
"version": "19.27.0.340",
"path": "mods/jei-1.21.1-neoforge-19.27.0.340.jar",
"url": "https://cdn.modrinth.com/data/u6dRKJwZ/versions/YAcQ6elZ/jei-1.21.1-neoforge-19.27.0.340.jar",
"sha1": "27d0d85e7e32e926fc3664ab6815df5cdabb7941",
"size": 1529391
},
{
"source": "modrinth",
"slug": "jade",
"versionId": "yd8FKCmx",
"version": "15.10.5+neoforge",
"path": "mods/Jade-1.21.1-NeoForge-15.10.5.jar",
"url": "https://cdn.modrinth.com/data/nvQzSEkH/versions/yd8FKCmx/Jade-1.21.1-NeoForge-15.10.5.jar",
"sha1": "d5bf134b3dbde9f5258666823900e21341dc0a50",
"size": 725742
},
{
"source": "modrinth",
"slug": "chunky",
"versionId": "LuFhm4eU",
"version": "1.4.23",
"path": "mods/Chunky-NeoForge-1.4.23.jar",
"url": "https://cdn.modrinth.com/data/fALzjamp/versions/LuFhm4eU/Chunky-NeoForge-1.4.23.jar",
"sha1": "ab0c74743a653020fe2dfc4986b43e893947f3e9",
"size": 340572
},
{
"source": "curseforge",
"slug": "ftb-library",
"fileId": "7746959",
"version": "2101.1.31",
"path": "mods/ftb-library-neoforge-2101.1.31.jar",
"url": "https://mediafilez.forgecdn.net/files/7746/959/ftb-library-neoforge-2101.1.31.jar",
"sha1": "686d4e784c28c14f7760cc22b2de6a8573b56b74",
"size": 1411181
},
{
"source": "curseforge",
"slug": "ftb-teams",
"fileId": "7369021",
"version": "2101.1.9",
"path": "mods/ftb-teams-neoforge-2101.1.9.jar",
"url": "https://mediafilez.forgecdn.net/files/7369/21/ftb-teams-neoforge-2101.1.9.jar",
"sha1": "328e04bf1a445870aacea8fe7637670f84272a8f",
"size": 291847
},
{
"source": "curseforge",
"slug": "ftb-chunks",
"fileId": "7608681",
"version": "2101.1.14",
"path": "mods/ftb-chunks-neoforge-2101.1.14.jar",
"url": "https://mediafilez.forgecdn.net/files/7608/681/ftb-chunks-neoforge-2101.1.14.jar",
"sha1": "908b63b11d0e00ae6c9557d3fe6440bdbcf21bb7",
"size": 642340
},
{
"source": "modrinth",
"slug": "ars-nouveau",
"versionId": "BmGGrC9A",
"version": "5.11.3+mc1.21.1",
"path": "mods/ars_nouveau-1.21.1-5.11.3.jar",
"url": "https://cdn.modrinth.com/data/TKB6INcv/versions/BmGGrC9A/ars_nouveau-1.21.1-5.11.3.jar",
"sha1": "0af12dd7fda63a4261ceb302c9bb57fc235641c6",
"size": 20689115
},
{
"source": "modrinth",
"slug": "terralith",
"versionId": "MuJMtPGQ",
"version": "2.5.8",
"path": "mods/Terralith_1.21.x_v2.5.8.jar",
"url": "https://cdn.modrinth.com/data/8oi3bsk5/versions/MuJMtPGQ/Terralith_1.21.x_v2.5.8.jar",
"sha1": "bee0cfb1a8cd4bf3d96bccea224fb45d74de9085",
"size": 3115385
},
{
"source": "modrinth",
"slug": "yungs-better-strongholds",
"versionId": "8U0dIfSM",
"version": "1.21.1-NeoForge-5.1.3",
"path": "mods/YungsBetterStrongholds-1.21.1-NeoForge-5.1.3.jar",
"url": "https://cdn.modrinth.com/data/kidLKymU/versions/8U0dIfSM/YungsBetterStrongholds-1.21.1-NeoForge-5.1.3.jar",
"sha1": "5d06a5850af7c577612d4592706a8e156bbe1cbf",
"size": 461244
},
{
"source": "modrinth",
"slug": "lithostitched",
"versionId": "IONexlgI",
"version": "1.7.2-neoforge-21.1",
"path": "mods/lithostitched-1.7.2-neoforge-21.1.jar",
"url": "https://cdn.modrinth.com/data/XaDC71GB/versions/IONexlgI/lithostitched-1.7.2-neoforge-21.1.jar",
"sha1": "ce35206214647131ebdf14212d1986349aeba79a",
"size": 810015
},
{
"source": "modrinth",
"slug": "c2me-neoforge",
"versionId": "9iPiN34N",
"version": "0.3.0+alpha.0.91+1.21.1",
"path": "mods/c2me-neoforge-mc1.21.1-0.3.0+alpha.0.91.jar",
"url": "https://cdn.modrinth.com/data/COlSi5iR/versions/9iPiN34N/c2me-neoforge-mc1.21.1-0.3.0%2Balpha.0.91.jar",
"sha1": "c858c8becfb5205eb12aaf0420eb82c307c2e6a7",
"size": 4508649
},
{
"source": "modrinth",
"slug": "noisium",
"versionId": "nJBE6tif",
"version": "2.3.0+mc1.21-1.21.1",
"path": "mods/noisium-neoforge-2.3.0+mc1.21-1.21.1.jar",
"url": "https://cdn.modrinth.com/data/KuNKN7d2/versions/nJBE6tif/noisium-neoforge-2.3.0%2Bmc1.21-1.21.1.jar",
"sha1": "1bea6b61378ba80f038256c4345d9ff3b67928c4",
"size": 60296
},
{
"source": "modrinth",
"slug": "async-locator-refined",
"versionId": "3BdGHbV2",
"version": "1.21.1-1.5.3",
"path": "mods/async-locator-refined-neoforge-1.21.1-1.5.3.jar",
"url": "https://cdn.modrinth.com/data/LUIHK4LD/versions/3BdGHbV2/async-locator-refined-neoforge-1.21.1-1.5.3.jar",
"sha1": "2993e3efc6d211ad8d4db179851dea6fdfff4e07",
"size": 273320
},
{
"source": "modrinth",
"slug": "servercore",
"versionId": "77MAnmOn",
"version": "1.5.10+1.21.1",
"path": "mods/servercore-neoforge-1.5.10+1.21.1.jar",
"url": "https://cdn.modrinth.com/data/4WWQxlQP/versions/77MAnmOn/servercore-neoforge-1.5.10%2B1.21.1.jar",
"sha1": "4524cd40cfa5019d8b5fbcb628b1616031838a0c",
"size": 1429522
},
{
"source": "modrinth",
"slug": "lithium",
"versionId": "RXHf27Wv",
"version": "mc1.21.1-0.15.3-neoforge",
"path": "mods/lithium-neoforge-0.15.3+mc1.21.1.jar",
"url": "https://cdn.modrinth.com/data/gvQqBUqZ/versions/RXHf27Wv/lithium-neoforge-0.15.3%2Bmc1.21.1.jar",
"sha1": "9fd5fa9076044180ae7f51672de74669196ec72e",
"size": 774148
},
{
"source": "modrinth",
"slug": "geckolib",
"versionId": "gFmrC8Ru",
"version": "4.8.4",
"path": "mods/geckolib-neoforge-1.21.1-4.8.4.jar",
"url": "https://cdn.modrinth.com/data/8BmcQJ2H/versions/gFmrC8Ru/geckolib-neoforge-1.21.1-4.8.4.jar",
"sha1": "eb854c8ec53ef922a5f3877a1aa4c1ce1352e0ce",
"size": 622582
},
{
"source": "modrinth",
"slug": "curios",
"versionId": "yohfFbgD",
"version": "9.5.1+1.21.1",
"path": "mods/curios-neoforge-9.5.1+1.21.1.jar",
"url": "https://cdn.modrinth.com/data/vvuO3ImH/versions/yohfFbgD/curios-neoforge-9.5.1%2B1.21.1.jar",
"sha1": "418fcd42e3a7844c9bdc71c9b6401fdb3894e0c4",
"size": 410690
},
{
"source": "modrinth",
"slug": "yungs-api",
"versionId": "ZB22DE9q",
"version": "1.21.1-NeoForge-5.1.6",
"path": "mods/YungsApi-1.21.1-NeoForge-5.1.6.jar",
"url": "https://cdn.modrinth.com/data/Ua7DFN59/versions/ZB22DE9q/YungsApi-1.21.1-NeoForge-5.1.6.jar",
"sha1": "e1c394779fb9e038e4f7a1b4558d0432607d263b",
"size": 388678
},
{
"source": "modrinth",
"slug": "complementary-reimagined",
"versionId": "836bPNGo",
"version": "r5.7.1",
"path": "shaderpacks/ComplementaryReimagined_r5.7.1.zip",
"url": "https://cdn.modrinth.com/data/HVnmMxH1/versions/836bPNGo/ComplementaryReimagined_r5.7.1.zip",
"sha1": "b560f646a124d5204b1fb7321fec373b9c346fa5",
"size": 522970
}
],
"defaultServer": {
"name": "Brass and Sigil",
"ip": "bns.sijbers.uk"
},
"defaultShader": "ComplementaryReimagined_r5.7.1.zip",
"panelUrl": "https://bns-admin.sijbers.uk"
"$schema": "Brass-and-Sigil pack.lock.json - generated, do not edit by hand unless you know what you are doing",
"name": "Brass and Sigil",
"version": "0.12.0",
"minecraft": "1.21.1",
"loader": {
"type": "neoforge",
"version": "21.1.228"
},
"lockedAt": "2026-05-04T13:22:58.7131203+00:00",
"mods": [
{
"source": "modrinth",
"slug": "create",
"versionId": "UjX6dr61",
"version": "6.0.10+mc1.21.1",
"path": "mods/create-1.21.1-6.0.10.jar",
"url": "https://cdn.modrinth.com/data/LNytGWDc/versions/UjX6dr61/create-1.21.1-6.0.10.jar",
"sha1": "0e97e49837bed766e6f28a4c95b04885d6acc353",
"size": 19123767,
"side": "both"
},
{
"source": "modrinth",
"slug": "create-aeronautics",
"versionId": "YhZLrAFC",
"version": "1.2.1+mc1.21.1",
"path": "mods/create-aeronautics-bundled-1.21.1-1.2.1.jar",
"url": "https://cdn.modrinth.com/data/oWaK0Q19/versions/YhZLrAFC/create-aeronautics-bundled-1.21.1-1.2.1.jar",
"sha1": "fdf1ae69e8b6437e0196b3a35dd2325aa904aba9",
"size": 33030286,
"side": "both"
},
{
"source": "modrinth",
"slug": "sable",
"versionId": "3FMsUjO4",
"version": "1.2.2+mc1.21.1",
"path": "mods/sable-neoforge-1.21.1-1.2.2.jar",
"url": "https://cdn.modrinth.com/data/T9PomCSv/versions/3FMsUjO4/sable-neoforge-1.21.1-1.2.2.jar",
"sha1": "c5ecd3fcf60a31d84112c708abe29e341b2d1b73",
"size": 12719293,
"side": "both"
},
{
"source": "modrinth",
"slug": "create-big-cannons",
"versionId": "bsGaXKEd",
"version": "5.11.3",
"path": "mods/createbigcannons-5.11.3+mc.1.21.1.jar",
"url": "https://cdn.modrinth.com/data/GWp4jCJj/versions/bsGaXKEd/createbigcannons-5.11.3%2Bmc.1.21.1.jar",
"sha1": "8b61fa850e260bdeb5d360576123f98c260afa50",
"size": 3715787,
"side": "both"
},
{
"source": "modrinth",
"slug": "create-tfmg",
"versionId": "uDi14nbt",
"version": "1.2.0",
"path": "mods/tfmg-1.2.0.jar",
"url": "https://cdn.modrinth.com/data/USgVjXsk/versions/uDi14nbt/tfmg-1.2.0.jar",
"sha1": "b520f3687f60a69eb265ff5b9a16759b9e124103",
"size": 4924243,
"side": "both"
},
{
"source": "modrinth",
"slug": "distanthorizons",
"versionId": "KkaaQtTD",
"version": "3.0.2-b-1.21.1",
"path": "mods/DistantHorizons-3.0.2-b-1.21.1-fabric-neoforge.jar",
"url": "https://cdn.modrinth.com/data/uCdwusMi/versions/KkaaQtTD/DistantHorizons-3.0.2-b-1.21.1-fabric-neoforge.jar",
"sha1": "1ff0a8920e52add541471f7b32d0d389997145ba",
"size": 30019727,
"side": "both"
},
{
"source": "modrinth",
"slug": "sodium",
"versionId": "Pb3OXVqC",
"version": "mc1.21.1-0.6.13-neoforge",
"path": "mods/sodium-neoforge-0.6.13+mc1.21.1.jar",
"url": "https://cdn.modrinth.com/data/AANobbMI/versions/Pb3OXVqC/sodium-neoforge-0.6.13%2Bmc1.21.1.jar",
"sha1": "38af70fa4dc4b2aaac636e92fdba3bedd5a025e1",
"size": 1162994,
"side": "client"
},
{
"source": "modrinth",
"slug": "iris",
"versionId": "t3ruzodq",
"version": "1.8.12+1.21.1-neoforge",
"path": "mods/iris-neoforge-1.8.12+mc1.21.1.jar",
"url": "https://cdn.modrinth.com/data/YL57xq9U/versions/t3ruzodq/iris-neoforge-1.8.12%2Bmc1.21.1.jar",
"sha1": "a3e6355915c7d3b2bc392724795113e51d289378",
"size": 2438548,
"side": "client"
},
{
"source": "modrinth",
"slug": "modernfix",
"versionId": "6U8JVjdw",
"version": "5.27.4+mc1.21.1",
"path": "mods/modernfix-neoforge-5.27.4+mc1.21.1.jar",
"url": "https://cdn.modrinth.com/data/nmDcB62a/versions/6U8JVjdw/modernfix-neoforge-5.27.4%2Bmc1.21.1.jar",
"sha1": "2f39363f0d6d5a5ccc2a9e0f50ad3385611c3cb7",
"size": 562051,
"side": "both"
},
{
"source": "modrinth",
"slug": "ferrite-core",
"versionId": "x7kQWVju",
"version": "7.0.3-neoforge",
"path": "mods/ferritecore-7.0.3-neoforge.jar",
"url": "https://cdn.modrinth.com/data/uXXizFIs/versions/x7kQWVju/ferritecore-7.0.3-neoforge.jar",
"sha1": "9563692efb708b6b568df27a01ec52f6311928ef",
"size": 121559,
"side": "both"
},
{
"source": "modrinth",
"slug": "architectury-api",
"versionId": "ZxYGwlk0",
"version": "13.0.8+neoforge",
"path": "mods/architectury-13.0.8-neoforge.jar",
"url": "https://cdn.modrinth.com/data/lhGA9TYQ/versions/ZxYGwlk0/architectury-13.0.8-neoforge.jar",
"sha1": "6ca11d3cc136bf69bb8f4d56982481eb85b5100b",
"size": 584004,
"side": "both"
},
{
"source": "modrinth",
"slug": "rhino",
"versionId": "ZdLtebKH",
"version": "2101.2.7-build.81+Rhino-1.21",
"path": "mods/rhino-2101.2.7-build.81.jar",
"url": "https://cdn.modrinth.com/data/sk9knFPE/versions/ZdLtebKH/rhino-2101.2.7-build.81.jar",
"sha1": "480235a9f7749f68ce6fec3b9c3cac3428b92a4a",
"size": 882033,
"side": "both"
},
{
"source": "modrinth",
"slug": "rpl",
"versionId": "hZ6B2Z0x",
"version": "2.1.2",
"path": "mods/ritchiesprojectilelib-2.1.2+mc.1.21.1-neoforge.jar",
"url": "https://cdn.modrinth.com/data/B3pb093D/versions/hZ6B2Z0x/ritchiesprojectilelib-2.1.2%2Bmc.1.21.1-neoforge.jar",
"sha1": "ec2e4996f8bee8714173e603e379fef8a6901765",
"size": 76369,
"side": "both"
},
{
"source": "modrinth",
"slug": "kubejs",
"versionId": "Fe9CjPws",
"version": "2101.7.2-build.363",
"path": "mods/kubejs-neoforge-2101.7.2-build.363.jar",
"url": "https://cdn.modrinth.com/data/umyGl7zF/versions/Fe9CjPws/kubejs-neoforge-2101.7.2-build.363.jar",
"sha1": "d4e88254e8c26687d4c6aeb4dfa9c2ad70f260a2",
"size": 2270442,
"side": "both"
},
{
"source": "modrinth",
"slug": "jei",
"versionId": "YAcQ6elZ",
"version": "19.27.0.340",
"path": "mods/jei-1.21.1-neoforge-19.27.0.340.jar",
"url": "https://cdn.modrinth.com/data/u6dRKJwZ/versions/YAcQ6elZ/jei-1.21.1-neoforge-19.27.0.340.jar",
"sha1": "27d0d85e7e32e926fc3664ab6815df5cdabb7941",
"size": 1529391,
"side": "both"
},
{
"source": "modrinth",
"slug": "jade",
"versionId": "yd8FKCmx",
"version": "15.10.5+neoforge",
"path": "mods/Jade-1.21.1-NeoForge-15.10.5.jar",
"url": "https://cdn.modrinth.com/data/nvQzSEkH/versions/yd8FKCmx/Jade-1.21.1-NeoForge-15.10.5.jar",
"sha1": "d5bf134b3dbde9f5258666823900e21341dc0a50",
"size": 725742,
"side": "both"
},
{
"source": "modrinth",
"slug": "chunky",
"versionId": "LuFhm4eU",
"version": "1.4.23",
"path": "mods/Chunky-NeoForge-1.4.23.jar",
"url": "https://cdn.modrinth.com/data/fALzjamp/versions/LuFhm4eU/Chunky-NeoForge-1.4.23.jar",
"sha1": "ab0c74743a653020fe2dfc4986b43e893947f3e9",
"size": 340572,
"side": "both"
},
{
"source": "curseforge",
"slug": "ftb-library",
"fileId": "7746959",
"version": "2101.1.31",
"path": "mods/ftb-library-neoforge-2101.1.31.jar",
"url": "https://mediafilez.forgecdn.net/files/7746/959/ftb-library-neoforge-2101.1.31.jar",
"sha1": "686d4e784c28c14f7760cc22b2de6a8573b56b74",
"size": 1411181,
"side": "both"
},
{
"source": "curseforge",
"slug": "ftb-teams",
"fileId": "7369021",
"version": "2101.1.9",
"path": "mods/ftb-teams-neoforge-2101.1.9.jar",
"url": "https://mediafilez.forgecdn.net/files/7369/21/ftb-teams-neoforge-2101.1.9.jar",
"sha1": "328e04bf1a445870aacea8fe7637670f84272a8f",
"size": 291847,
"side": "both"
},
{
"source": "curseforge",
"slug": "ftb-chunks",
"fileId": "7608681",
"version": "2101.1.14",
"path": "mods/ftb-chunks-neoforge-2101.1.14.jar",
"url": "https://mediafilez.forgecdn.net/files/7608/681/ftb-chunks-neoforge-2101.1.14.jar",
"sha1": "908b63b11d0e00ae6c9557d3fe6440bdbcf21bb7",
"size": 642340,
"side": "both"
},
{
"source": "modrinth",
"slug": "ars-nouveau",
"versionId": "BmGGrC9A",
"version": "5.11.3+mc1.21.1",
"path": "mods/ars_nouveau-1.21.1-5.11.3.jar",
"url": "https://cdn.modrinth.com/data/TKB6INcv/versions/BmGGrC9A/ars_nouveau-1.21.1-5.11.3.jar",
"sha1": "0af12dd7fda63a4261ceb302c9bb57fc235641c6",
"size": 20689115,
"side": "both"
},
{
"source": "modrinth",
"slug": "terralith",
"versionId": "MuJMtPGQ",
"version": "2.5.8",
"path": "mods/Terralith_1.21.x_v2.5.8.jar",
"url": "https://cdn.modrinth.com/data/8oi3bsk5/versions/MuJMtPGQ/Terralith_1.21.x_v2.5.8.jar",
"sha1": "bee0cfb1a8cd4bf3d96bccea224fb45d74de9085",
"size": 3115385,
"side": "both"
},
{
"source": "modrinth",
"slug": "yungs-better-strongholds",
"versionId": "8U0dIfSM",
"version": "1.21.1-NeoForge-5.1.3",
"path": "mods/YungsBetterStrongholds-1.21.1-NeoForge-5.1.3.jar",
"url": "https://cdn.modrinth.com/data/kidLKymU/versions/8U0dIfSM/YungsBetterStrongholds-1.21.1-NeoForge-5.1.3.jar",
"sha1": "5d06a5850af7c577612d4592706a8e156bbe1cbf",
"size": 461244,
"side": "server"
},
{
"source": "modrinth",
"slug": "lithostitched",
"versionId": "IONexlgI",
"version": "1.7.2-neoforge-21.1",
"path": "mods/lithostitched-1.7.2-neoforge-21.1.jar",
"url": "https://cdn.modrinth.com/data/XaDC71GB/versions/IONexlgI/lithostitched-1.7.2-neoforge-21.1.jar",
"sha1": "ce35206214647131ebdf14212d1986349aeba79a",
"size": 810015,
"side": "server"
},
{
"source": "modrinth",
"slug": "c2me-neoforge",
"versionId": "9iPiN34N",
"version": "0.3.0+alpha.0.91+1.21.1",
"path": "mods/c2me-neoforge-mc1.21.1-0.3.0+alpha.0.91.jar",
"url": "https://cdn.modrinth.com/data/COlSi5iR/versions/9iPiN34N/c2me-neoforge-mc1.21.1-0.3.0%2Balpha.0.91.jar",
"sha1": "c858c8becfb5205eb12aaf0420eb82c307c2e6a7",
"size": 4508649,
"side": "both"
},
{
"source": "modrinth",
"slug": "noisium",
"versionId": "nJBE6tif",
"version": "2.3.0+mc1.21-1.21.1",
"path": "mods/noisium-neoforge-2.3.0+mc1.21-1.21.1.jar",
"url": "https://cdn.modrinth.com/data/KuNKN7d2/versions/nJBE6tif/noisium-neoforge-2.3.0%2Bmc1.21-1.21.1.jar",
"sha1": "1bea6b61378ba80f038256c4345d9ff3b67928c4",
"size": 60296,
"side": "server"
},
{
"source": "modrinth",
"slug": "async-locator-refined",
"versionId": "3BdGHbV2",
"version": "1.21.1-1.5.3",
"path": "mods/async-locator-refined-neoforge-1.21.1-1.5.3.jar",
"url": "https://cdn.modrinth.com/data/LUIHK4LD/versions/3BdGHbV2/async-locator-refined-neoforge-1.21.1-1.5.3.jar",
"sha1": "2993e3efc6d211ad8d4db179851dea6fdfff4e07",
"size": 273320,
"side": "server"
},
{
"source": "modrinth",
"slug": "servercore",
"versionId": "77MAnmOn",
"version": "1.5.10+1.21.1",
"path": "mods/servercore-neoforge-1.5.10+1.21.1.jar",
"url": "https://cdn.modrinth.com/data/4WWQxlQP/versions/77MAnmOn/servercore-neoforge-1.5.10%2B1.21.1.jar",
"sha1": "4524cd40cfa5019d8b5fbcb628b1616031838a0c",
"size": 1429522,
"side": "server"
},
{
"source": "modrinth",
"slug": "lithium",
"versionId": "RXHf27Wv",
"version": "mc1.21.1-0.15.3-neoforge",
"path": "mods/lithium-neoforge-0.15.3+mc1.21.1.jar",
"url": "https://cdn.modrinth.com/data/gvQqBUqZ/versions/RXHf27Wv/lithium-neoforge-0.15.3%2Bmc1.21.1.jar",
"sha1": "9fd5fa9076044180ae7f51672de74669196ec72e",
"size": 774148,
"side": "both"
},
{
"source": "modrinth",
"slug": "geckolib",
"versionId": "gFmrC8Ru",
"version": "4.8.4",
"path": "mods/geckolib-neoforge-1.21.1-4.8.4.jar",
"url": "https://cdn.modrinth.com/data/8BmcQJ2H/versions/gFmrC8Ru/geckolib-neoforge-1.21.1-4.8.4.jar",
"sha1": "eb854c8ec53ef922a5f3877a1aa4c1ce1352e0ce",
"size": 622582,
"side": "both"
},
{
"source": "modrinth",
"slug": "curios",
"versionId": "yohfFbgD",
"version": "9.5.1+1.21.1",
"path": "mods/curios-neoforge-9.5.1+1.21.1.jar",
"url": "https://cdn.modrinth.com/data/vvuO3ImH/versions/yohfFbgD/curios-neoforge-9.5.1%2B1.21.1.jar",
"sha1": "418fcd42e3a7844c9bdc71c9b6401fdb3894e0c4",
"size": 410690,
"side": "both"
},
{
"source": "modrinth",
"slug": "yungs-api",
"versionId": "ZB22DE9q",
"version": "1.21.1-NeoForge-5.1.6",
"path": "mods/YungsApi-1.21.1-NeoForge-5.1.6.jar",
"url": "https://cdn.modrinth.com/data/Ua7DFN59/versions/ZB22DE9q/YungsApi-1.21.1-NeoForge-5.1.6.jar",
"sha1": "e1c394779fb9e038e4f7a1b4558d0432607d263b",
"size": 388678,
"side": "both"
},
{
"source": "modrinth",
"slug": "complementary-reimagined",
"versionId": "836bPNGo",
"version": "r5.7.1",
"path": "shaderpacks/ComplementaryReimagined_r5.7.1.zip",
"url": "https://cdn.modrinth.com/data/HVnmMxH1/versions/836bPNGo/ComplementaryReimagined_r5.7.1.zip",
"sha1": "b560f646a124d5204b1fb7321fec373b9c346fa5",
"size": 522970,
"side": "client"
},
{
"source": "modrinth",
"slug": "simple-voice-chat",
"versionId": "6rT2RWh6",
"version": "neoforge-1.21.1-2.6.17",
"path": "mods/voicechat-neoforge-1.21.1-2.6.17.jar",
"url": "https://cdn.modrinth.com/data/9eGKb6K1/versions/6rT2RWh6/voicechat-neoforge-1.21.1-2.6.17.jar",
"sha1": "55fc0d529318620bd2ce7a635657014c5c98e189",
"size": 4902096,
"side": "both"
},
{
"source": "modrinth",
"slug": "almostunified",
"versionId": "e8iYxxI3",
"version": "1.21.1-1.4.2+neoforge",
"path": "mods/almostunified-neoforge-1.21.1-1.4.2.jar",
"url": "https://cdn.modrinth.com/data/sdaSaQEz/versions/e8iYxxI3/almostunified-neoforge-1.21.1-1.4.2.jar",
"sha1": "f9a58fa95780f4b045d30559c1fdaedaa7f0fba3",
"size": 295093,
"side": "both"
}
],
"defaultServer": {
"name": "Brass and Sigil",
"ip": "bns.sijbers.uk"
},
"defaultShader": "ComplementaryReimagined_r5.7.1.zip",
"panelUrl": "https://bns-admin.sijbers.uk"
}
+96
View File
@@ -0,0 +1,96 @@
#requires -Version 7
<#
.SYNOPSIS
Populate the `side` field on each mod entry in pack.lock.json by querying
Modrinth's project metadata. Run once after schema migration; thereafter,
Update-Pack.ps1 should set side automatically when adding new mods.
.DESCRIPTION
Modrinth returns `client_side` and `server_side` per project, with values
`required` / `optional` / `unsupported` / `unknown`. We map:
server_side = unsupported -> side = "client" (skip on server)
client_side = unsupported -> side = "server" (skip on launcher)
both required/optional/unknown -> side = "both" (install everywhere)
Conservative: only marks a mod side-restricted when the OTHER side is
explicitly `unsupported`. Anything ambiguous stays "both".
CurseForge mods can't be queried (no API key here) -- left at "both" by
default, manual edit if needed.
.PARAMETER LockPath
pack.lock.json to update in place. Defaults to ../pack/pack.lock.json.
.PARAMETER DryRun
Print proposed changes without writing the file.
#>
[CmdletBinding()]
param(
[string]$LockPath = $(Join-Path (Split-Path -Parent $MyInvocation.MyCommand.Path) '..\pack\pack.lock.json'),
[switch]$DryRun
)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
$LockPath = (Resolve-Path $LockPath).Path
$json = Get-Content $LockPath -Raw | ConvertFrom-Json -Depth 20
if (-not $json.mods) { throw "pack.lock.json has no 'mods' array." }
function Get-ModrinthSide {
param([string]$Slug)
$proj = Invoke-RestMethod -Uri "https://api.modrinth.com/v2/project/$Slug" `
-Headers @{ 'User-Agent' = 'BrassAndSigil-Launcher/0.1 (matt@sijbers.uk)' }
$cs = $proj.client_side; $ss = $proj.server_side
if ($ss -eq 'unsupported' -and $cs -ne 'unsupported') { return 'client' }
elseif ($cs -eq 'unsupported' -and $ss -ne 'unsupported') { return 'server' }
else { return 'both' }
}
Write-Host ""
Write-Host "Bootstrapping side field on $($json.mods.Count) mods..."
Write-Host ""
$changes = 0
foreach ($mod in $json.mods) {
$existing = if ($mod.PSObject.Properties.Name -contains 'side') { $mod.side } else { $null }
if ($mod.source -ne 'modrinth') {
# CurseForge or other: default to 'both' if missing, leave alone otherwise.
if (-not $existing) {
$mod | Add-Member -NotePropertyName 'side' -NotePropertyValue 'both' -Force
Write-Host (" [{0}] {1,-26} {2}" -f $mod.source.PadRight(7).Substring(0,7), $mod.slug, 'both (default; non-modrinth)')
$changes++
}
continue
}
try {
$side = Get-ModrinthSide -Slug $mod.slug
} catch {
Write-Warning (" $($mod.slug) lookup failed: $($_.Exception.Message) -- leaving as 'both'")
$side = 'both'
}
if ($existing -ne $side) {
$mod | Add-Member -NotePropertyName 'side' -NotePropertyValue $side -Force
Write-Host (" [{0}] {1,-26} {2}" -f $mod.source.PadRight(7).Substring(0,7), $mod.slug, $side)
$changes++
} else {
Write-Host (" [{0}] {1,-26} {2} (unchanged)" -f $mod.source.PadRight(7).Substring(0,7), $mod.slug, $side) -ForegroundColor DarkGray
}
Start-Sleep -Milliseconds 100 # be polite to Modrinth's API
}
Write-Host ""
Write-Host "$changes mod(s) updated."
if ($DryRun) {
Write-Host "DryRun: not writing $LockPath." -ForegroundColor DarkGray
exit 0
}
# Round-trip via JSON: Modrinth-returned ConvertFrom-Json objects don't always
# round-trip with consistent property order, so we serialise with sorted keys
# at the mod level to keep the lockfile diff minimal.
$json | ConvertTo-Json -Depth 20 | Set-Content -Path $LockPath -Encoding utf8
Write-Host "Wrote $LockPath" -ForegroundColor Green
+28 -4
View File
@@ -77,14 +77,21 @@ $files = @()
$totalBytes = 0L
foreach ($mod in $lock.mods) {
$files += [pscustomobject]@{
# `side` is emitted only when explicitly set in the lockfile -- a missing or
# null lockfile side means "both", and we keep the manifest clean by omitting
# the field rather than serialising "both" everywhere.
$entry = [ordered]@{
path = $mod.path
url = $mod.url
sha1 = $mod.sha1
size = $mod.size
}
$hasSide = ($mod.PSObject.Properties.Name -contains 'side') -and $mod.side -and $mod.side -ne 'both'
if ($hasSide) { $entry.side = $mod.side }
$files += [pscustomobject]$entry
$totalBytes += $mod.size
Write-Host (" [{0}] {1,-26} {2,-22} {3,8:N0} KB" -f $mod.source.PadRight(7).Substring(0,7), $mod.slug, $mod.version, ($mod.size/1KB))
$sideTag = if ($hasSide) { " [$($mod.side)]" } else { '' }
Write-Host (" [{0}] {1,-26} {2,-22} {3,8:N0} KB{4}" -f $mod.source.PadRight(7).Substring(0,7), $mod.slug, $mod.version, ($mod.size/1KB), $sideTag)
}
# Local overrides (configs, custom files not on Modrinth/CurseForge)
@@ -156,8 +163,25 @@ if ($LauncherExePath) {
}
$launcherFile = Get-Item $LauncherExePath
$launcherVersion = $launcherFile.VersionInfo.FileVersion
$versionSource = 'PE FileVersion'
# Linux PowerShell can't read PE FileVersion from a Windows .exe. When that
# happens, fall back to launcher/ModpackLauncher.csproj's <Version> (the
# source of truth that the Windows compile bakes into FileVersion anyway).
# Append ".0" so callers get the same 4-part form as the PE metadata.
if ([string]::IsNullOrWhiteSpace($launcherVersion)) {
throw "Launcher exe has no FileVersion -- set <Version> in ModpackLauncher.csproj and republish."
$csprojPath = Join-Path $here '..\launcher\ModpackLauncher.csproj'
if (Test-Path $csprojPath) {
[xml]$csproj = Get-Content $csprojPath
$csprojVersion = ($csproj.Project.PropertyGroup.Version | Where-Object { $_ }) | Select-Object -First 1
if ($csprojVersion) {
$launcherVersion = if ($csprojVersion -match '^\d+\.\d+\.\d+$') { "$csprojVersion.0" } else { $csprojVersion }
$versionSource = "csproj <Version> (fallback)"
}
}
}
if ([string]::IsNullOrWhiteSpace($launcherVersion)) {
throw "Launcher exe has no FileVersion and csproj <Version> couldn't be read either -- set <Version> in ModpackLauncher.csproj and republish."
}
# FileVersion is the four-component form (e.g. "0.1.0.0" for csproj <Version>0.1.0</Version>).
# Embed as-is -- the launcher's Version.Parse handles it directly and avoids ambiguous
@@ -167,7 +191,7 @@ if ($LauncherExePath) {
Write-Host ""
Write-Host ("Launcher metadata embedded:")
Write-Host (" Version: {0}" -f $launcherVersion)
Write-Host (" Version: {0} ({1})" -f $launcherVersion, $versionSource)
Write-Host (" Url: {0}" -f $LauncherPublicUrl)
Write-Host (" Source: {0}" -f $launcherFile.FullName)
}
+62 -20
View File
@@ -52,7 +52,10 @@ if (-not (Test-Path $cfgPath)) {
. $cfgPath
# Sanity-check required vars actually got set (template ships with CHANGE_ME).
foreach ($v in 'DeployShare','ServerSshHost','ServerSshKey','ServerBinaryRemote') {
# $ServerSshKey is only required for SSH-mode deploys; skip it when ServerSshHost = 'local'.
$requiredVars = @('DeployShare','ServerSshHost','ServerBinaryRemote')
if ($ServerSshHost -ne 'local') { $requiredVars += 'ServerSshKey' }
foreach ($v in $requiredVars) {
$val = Get-Variable -Name $v -ValueOnly -ErrorAction Stop
if ($val -match 'CHANGE_ME' -or [string]::IsNullOrWhiteSpace($val)) {
throw "deploy.config.ps1 has placeholder/empty `$$v. Fill it in."
@@ -120,14 +123,26 @@ if (($shouldRunPack -or $shouldRunLauncher) -and -not $DryRun -and -not $Force)
# actually broken -- the atomic swap is safe with the daemon running). But
# knowing the state up front lets us tailor the final "next steps" message
# so a deploy success doesn't silently leave you on the old code.
# Set $ServerSshHost = 'local' in deploy.config.ps1 to skip SSH entirely
# and copy/inspect the server binary on the local filesystem. Use when the
# deploy machine IS the server (e.g. running Deploy-Brass.ps1 on glados).
$isLocalDeploy = ($ServerSshHost -eq 'local')
$daemonWasRunning = $false
if ($shouldRunServer) {
Write-Host "Pre-flight: checking daemon state on $ServerSshHost..." -ForegroundColor DarkGray
# Single-quoted PS string so PowerShell doesn't try to interpret the
# bash-side metacharacters. The remote shell sees the literal pgrep
# command; the trailing $ anchors so we don't match run.sh wrappers.
$remoteCmd = 'pgrep -f /brass-sigil-server$ 2>/dev/null'
$pgrepOut = & ssh -i $ServerSshKey -o ConnectTimeout=5 -o BatchMode=yes $ServerSshHost $remoteCmd 2>$null
if ($isLocalDeploy) {
Write-Host "Pre-flight: checking daemon state locally..." -ForegroundColor DarkGray
$pgrepOut = & pgrep -f '/brass-sigil-server($| )' 2>$null
} else {
Write-Host "Pre-flight: checking daemon state on $ServerSshHost..." -ForegroundColor DarkGray
# Single-quoted PS string so PowerShell doesn't try to interpret the
# bash-side metacharacters. Match the binary path followed by either
# end-of-line OR a space, so we catch `brass-sigil-server run` (the
# actual invocation) without matching the `/brass-sigil-server/...`
# directory path that appears in the run.sh wrapper's argv.
$remoteCmd = 'pgrep -f /brass-sigil-server($| ) 2>/dev/null'
$pgrepOut = & ssh -i $ServerSshKey -o ConnectTimeout=5 -o BatchMode=yes $ServerSshHost $remoteCmd 2>$null
}
$daemonWasRunning = -not [string]::IsNullOrWhiteSpace($pgrepOut)
if ($daemonWasRunning) {
Write-Host " Daemon is RUNNING. Atomic swap is safe -- but you'll need to" -ForegroundColor Yellow
@@ -199,11 +214,26 @@ $overridesLocal = Join-Path $repoRoot 'pack\overrides'
$shareFiles = Join-Path $DeployShare 'files'
if ($shouldRunPack -and (Test-Path $overridesLocal)) {
Step "Mirror pack/overrides/ -> $shareFiles" {
# /MIR makes destination match source (deletes orphan files in $shareFiles).
# /XJ skips junctions, /R:1 /W:1 keeps retry behaviour sane on flaky shares.
robocopy $overridesLocal $shareFiles /MIR /XJ /R:1 /W:1 /NFL /NDL /NJH /NJS /NP | Out-Host
# Robocopy returns 0-7 for success-with-info; 8+ is real failure.
if ($LASTEXITCODE -ge 8) { throw "robocopy failed with exit $LASTEXITCODE" }
# Prefer rsync where available (Linux/macOS); fall back to Windows robocopy.
# Both perform a mirror: orphan files in the destination are removed so
# the share matches the local overrides exactly.
if (Get-Command rsync -ErrorAction SilentlyContinue) {
$src = ($overridesLocal -replace '\\','/').TrimEnd('/') + '/'
$dst = ($shareFiles -replace '\\','/').TrimEnd('/') + '/'
if (-not (Test-Path $shareFiles)) { New-Item -ItemType Directory -Path $shareFiles -Force | Out-Null }
& rsync -a --delete $src $dst
if ($LASTEXITCODE -ne 0) { throw "rsync failed with exit $LASTEXITCODE" }
}
elseif (Get-Command robocopy -ErrorAction SilentlyContinue) {
# /MIR makes destination match source (deletes orphan files in $shareFiles).
# /XJ skips junctions, /R:1 /W:1 keeps retry behaviour sane on flaky shares.
robocopy $overridesLocal $shareFiles /MIR /XJ /R:1 /W:1 /NFL /NDL /NJH /NJS /NP | Out-Host
# Robocopy returns 0-7 for success-with-info; 8+ is real failure.
if ($LASTEXITCODE -ge 8) { throw "robocopy failed with exit $LASTEXITCODE" }
}
else {
throw "Neither rsync nor robocopy is available -- can't mirror pack overrides."
}
}
}
@@ -224,15 +254,27 @@ if ($shouldRunPack) {
}
}
# ─── Stage 6: scp + atomic swap server binary ─────────────────────────────
# ─── Stage 6: copy + atomic swap server binary ─────────────────────────────
if ($shouldRunServer) {
Step "scp server binary -> $ServerSshHost`:$ServerBinaryRemote (atomic swap)" {
$remoteNew = "$ServerBinaryRemote.new"
& scp -i $ServerSshKey -o ConnectTimeout=15 $serverExe "$ServerSshHost`:$remoteNew"
if ($LASTEXITCODE -ne 0) { throw "scp failed with exit $LASTEXITCODE" }
$cmd = "chmod +x '$remoteNew' && mv '$remoteNew' '$ServerBinaryRemote' && md5sum '$ServerBinaryRemote'"
& ssh -i $ServerSshKey -o ConnectTimeout=10 $ServerSshHost $cmd
if ($LASTEXITCODE -ne 0) { throw "ssh swap failed with exit $LASTEXITCODE" }
if ($isLocalDeploy) {
Step "Copy server binary -> $ServerBinaryRemote (local atomic swap)" {
$localNew = "$ServerBinaryRemote.new"
Copy-Item $serverExe $localNew -Force
& chmod +x $localNew
if ($LASTEXITCODE -ne 0) { throw "chmod failed with exit $LASTEXITCODE" }
Move-Item $localNew $ServerBinaryRemote -Force
$md5 = (& md5sum $ServerBinaryRemote) -split '\s+' | Select-Object -First 1
Write-Host " md5: $md5" -ForegroundColor DarkGray
}
} else {
Step "scp server binary -> $ServerSshHost`:$ServerBinaryRemote (atomic swap)" {
$remoteNew = "$ServerBinaryRemote.new"
& scp -i $ServerSshKey -o ConnectTimeout=15 $serverExe "$ServerSshHost`:$remoteNew"
if ($LASTEXITCODE -ne 0) { throw "scp failed with exit $LASTEXITCODE" }
$cmd = "chmod +x '$remoteNew' && mv '$remoteNew' '$ServerBinaryRemote' && md5sum '$ServerBinaryRemote'"
& ssh -i $ServerSshKey -o ConnectTimeout=10 $ServerSshHost $cmd
if ($LASTEXITCODE -ne 0) { throw "ssh swap failed with exit $LASTEXITCODE" }
}
}
Write-Host ""
if ($daemonWasRunning) {
+9 -2
View File
@@ -19,13 +19,20 @@ $ManifestPublicUrl = 'https://CHANGE_ME/pack/manifest.json'
# ─── Server (brass-sigil-server daemon host) ───────────────────────────────
# user@host for the Linux box running the daemon.
#
# Set to the literal string 'local' if Deploy-Brass.ps1 is being run on the
# server itself (e.g. on glados over the Telegram bridge). In local mode the
# script skips scp/ssh and copies the binary directly to $ServerBinaryRemote
# with an in-place atomic rename; $ServerSshKey is ignored.
$ServerSshHost = 'user@CHANGE_ME'
# Path to the local SSH private key authorised on the server.
# Ignored when $ServerSshHost = 'local'.
$ServerSshKey = "$env:USERPROFILE\.ssh\id_ed25519"
# Absolute path on the Linux box where the brass-sigil-server binary lives.
# `Deploy-Brass.ps1` uploads to "<this>.new" then `mv` over for atomic swap.
# Absolute path where the brass-sigil-server binary lives -- on the remote
# host in SSH mode, on the local filesystem in 'local' mode.
# `Deploy-Brass.ps1` writes to "<this>.new" then renames over for atomic swap.
$ServerBinaryRemote = '/home/user/brass-sigil-server/brass-sigil-server'
# ─── Build outputs (don't normally need to change) ─────────────────────────
+7
View File
@@ -37,6 +37,12 @@ public sealed class ManifestFile
[JsonPropertyName("url")] public string Url { get; set; } = "";
[JsonPropertyName("sha1")] public string? Sha1 { get; set; }
[JsonPropertyName("size")] public long? Size { get; set; }
/// <summary>
/// "both" (default), "client", or "server". Server skips "client" files;
/// launcher skips "server" files. Null/missing = "both".
/// </summary>
[JsonPropertyName("side")] public string? Side { get; set; }
}
public sealed class PackLock
@@ -59,4 +65,5 @@ public sealed class LockedMod
[JsonPropertyName("url")] public string Url { get; set; } = "";
[JsonPropertyName("sha1")] public string Sha1 { get; set; } = "";
[JsonPropertyName("size")] public long Size { get; set; }
[JsonPropertyName("side")] public string? Side { get; set; }
}
+14 -3
View File
@@ -38,12 +38,20 @@ public sealed class ManifestSync
progress?.Report($"Pack: {manifest.Name} v{manifest.Version}");
Directory.CreateDirectory(serverDir);
// Resolve which mods are server-side.
var skipSlugs = await ResolveServerSideSkipListAsync(manifest, ct);
// Build-time tagging is authoritative: anything marked "client" is dropped
// outright, anything marked "server" or "both" is kept. We only fall back
// to the runtime Modrinth lookup for files with NO explicit side -- this
// keeps un-tagged (or CurseForge-only) mods safe and matches pre-side
// behaviour for them.
var needsLookup = manifest.Files.Where(f => string.IsNullOrEmpty(f.Side)).ToList();
var skipSlugs = needsLookup.Count > 0
? await ResolveServerSideSkipListAsync(
new Manifest { Files = needsLookup }, ct)
: new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// Build the filtered list of files to keep on the server.
var keepFiles = manifest.Files
.Where(f => !ShouldSkipFile(f.Path, skipSlugs))
.Where(f => !IsClientOnly(f) && (!string.IsNullOrEmpty(f.Side) || !ShouldSkipFile(f.Path, skipSlugs)))
.ToList();
var skippedCount = manifest.Files.Count - keepFiles.Count;
@@ -108,6 +116,9 @@ public sealed class ManifestSync
|| name.Equals(slug, StringComparison.OrdinalIgnoreCase));
}
private static bool IsClientOnly(ManifestFile f)
=> string.Equals(f.Side, "client", StringComparison.OrdinalIgnoreCase);
/// <summary>Walk the manifest's mod URLs; for Modrinth ones, look up server_side; build a skip set.</summary>
private async Task<HashSet<string>> ResolveServerSideSkipListAsync(Manifest manifest, CancellationToken ct)
{