From 56ecf489a09360abb0eb54593381aa8491f94953 Mon Sep 17 00:00:00 2001 From: Londopy Date: Sat, 7 Nov 2026 00:57:59 -0800 Subject: [PATCH 1/3] feat: add .rhs skin file drag-and-drop import Import .rhs skin archives by dropping them onto the game window. Extracts cursor, grid, colorset, hit sound, and composites background layers into the Rhythia skin folder format, then activates the skin. --- scripts/Rhythia.cs | 24 +++ scripts/skinning/RhsImporter.cs | 311 ++++++++++++++++++++++++++++++++ 2 files changed, 335 insertions(+) create mode 100644 scripts/skinning/RhsImporter.cs diff --git a/scripts/Rhythia.cs b/scripts/Rhythia.cs index 15e26357..2048f3b5 100644 --- a/scripts/Rhythia.cs +++ b/scripts/Rhythia.cs @@ -141,6 +141,30 @@ public override async void _Ready() replays.Add(replay); break; + + case "rhs": + try + { + string skinName = RhsImporter.Import(file); + + if (skinName != null) + { + var settings = SettingsManager.Instance.Settings; + + if (settings.Skin.List.Values.IndexOf(skinName) == -1) + { + settings.Skin.List.Values.Add(skinName); + } + + settings.Skin.Value = skinName; + } + } + catch (Exception ex) + { + Logger.Error(ex); + ToastNotification.Notify($"Failed to import skin: {ex.Message}", 2); + } + break; } } } diff --git a/scripts/skinning/RhsImporter.cs b/scripts/skinning/RhsImporter.cs new file mode 100644 index 00000000..118363db --- /dev/null +++ b/scripts/skinning/RhsImporter.cs @@ -0,0 +1,311 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text.Json; +using Godot; + +public static class RhsImporter +{ + private const int BG_WIDTH = 1920; + private const int BG_HEIGHT = 1080; + private const double SPACE_UNITS_PER_SCREEN_HEIGHT = 5.25; + + public static string Import(string rhsPath) + { + string baseName = Path.GetFileNameWithoutExtension(rhsPath).Replace('.', '-'); + string skinName = baseName; + string skinDir = $"{Constants.USER_FOLDER}/skins/{skinName}"; + + if (Directory.Exists(skinDir)) + { + int i = 2; + while (Directory.Exists($"{Constants.USER_FOLDER}/skins/{baseName} ({i})")) + i++; + skinName = $"{baseName} ({i})"; + skinDir = $"{Constants.USER_FOLDER}/skins/{skinName}"; + } + + string gameDir = $"{skinDir}/game"; + Directory.CreateDirectory(gameDir); + + using var archive = ZipFile.OpenRead(rhsPath); + + var configEntry = archive.GetEntry("config"); + if (configEntry == null) + { + Logger.Error("RHS import failed: no config entry"); + return null; + } + + JsonElement config; + using (var reader = new StreamReader(configEntry.Open())) + { + config = JsonSerializer.Deserialize(reader.ReadToEnd()); + } + + ExtractFirstImage(archive, "cursorSkin/", $"{gameDir}/cursor.png"); + ExtractFirstImage(archive, "cursorTrailSkin/", $"{gameDir}/cursor_trail.png"); + ExtractFirstImage(archive, "borderSkin/", $"{gameDir}/grid.png"); + ExtractColorSet(archive, skinName); + ExtractFirstFile(archive, "hitSound/", $"{skinDir}/hit.mp3"); + + bool hasBackground = ComposeBackground(archive, config, gameDir); + + string gameSpace = hasBackground ? "void" : "grid"; + WriteConfig(skinDir, skinName, gameSpace); + + Logger.Log($"Imported RHS skin: {skinName}"); + return skinName; + } + + private static JsonElement Unwrap(JsonElement root, string property) + { + if (!root.TryGetProperty(property, out var el)) + return default; + if (el.ValueKind == JsonValueKind.Object && el.TryGetProperty("Value", out var inner)) + return inner; + return el; + } + + private static int UnwrapInt(JsonElement root, string property, int fallback = 0) + { + var el = Unwrap(root, property); + return el.ValueKind == JsonValueKind.Number ? el.GetInt32() : fallback; + } + + private static double UnwrapDouble(JsonElement root, string property, double fallback = 0) + { + var el = Unwrap(root, property); + return el.ValueKind == JsonValueKind.Number ? el.GetDouble() : fallback; + } + + private static void ExtractFirstImage(ZipArchive archive, string prefix, string destPath) + { + var entry = archive.Entries.FirstOrDefault(e => + e.FullName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) + && e.Length > 0 + && IsImageExt(Path.GetExtension(e.Name))); + + if (entry == null) return; + + var image = LoadImage(ReadEntry(entry), entry.Name); + image?.SavePng(destPath); + } + + private static void ExtractFirstFile(ZipArchive archive, string prefix, string destPath) + { + var entry = archive.Entries.FirstOrDefault(e => + e.FullName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) + && e.Length > 0); + + if (entry == null) return; + + using var src = entry.Open(); + using var dst = File.Create(destPath); + src.CopyTo(dst); + } + + private static void ExtractColorSet(ZipArchive archive, string skinName) + { + var entry = archive.Entries.FirstOrDefault(e => + e.FullName.StartsWith("colorSet/", StringComparison.OrdinalIgnoreCase) + && e.Length > 0); + + if (entry == null) return; + + string dir = $"{Constants.USER_FOLDER}/colorsets"; + Directory.CreateDirectory(dir); + + using var reader = new StreamReader(entry.Open()); + File.WriteAllText($"{dir}/{skinName}.txt", reader.ReadToEnd().Trim()); + } + + private static bool ComposeBackground(ZipArchive archive, JsonElement config, string gameDir) + { + var bgEntries = archive.Entries + .Where(e => e.FullName.StartsWith("backgrounds/", StringComparison.OrdinalIgnoreCase) + && e.Length > 0 + && IsImageExt(Path.GetExtension(e.Name))) + .ToList(); + + if (bgEntries.Count == 0) return false; + + int bgR = UnwrapInt(config, "BackgroundRed"); + int bgG = UnwrapInt(config, "BackgroundGreen"); + int bgB = UnwrapInt(config, "BackgroundBlue"); + + var canvas = Image.Create(BG_WIDTH, BG_HEIGHT, false, Image.Format.Rgba8); + canvas.Fill(new Color(bgR / 255f, bgG / 255f, bgB / 255f, 1f)); + + var layers = Unwrap(config, "BackgroundImages"); + if (layers.ValueKind == JsonValueKind.Array) + { + int idx = 0; + foreach (var layer in layers.EnumerateArray()) + { + DrawLayer(canvas, archive, bgEntries, layer, idx); + idx++; + } + } + else if (bgEntries.Count > 0) + { + var img = LoadImage(ReadEntry(bgEntries[0]), bgEntries[0].Name); + if (img != null) + { + img.Resize(BG_WIDTH, BG_HEIGHT, Image.Interpolation.Lanczos); + canvas.BlendRect(img, new Rect2I(0, 0, BG_WIDTH, BG_HEIGHT), Vector2I.Zero); + } + } + + canvas.SavePng($"{gameDir}/background.png"); + return true; + } + + private static void DrawLayer( + Image canvas, + ZipArchive archive, + System.Collections.Generic.List bgEntries, + JsonElement layer, + int index) + { + double tintOpacity = layer.TryGetProperty("TintOpacity", out var opEl) ? opEl.GetDouble() : 1.0; + if (tintOpacity <= 0) return; + + string layerPath = layer.TryGetProperty("Path", out var pEl) ? pEl.GetString() ?? "" : ""; + string fileName = Path.GetFileName(layerPath); + + var entry = bgEntries.FirstOrDefault(e => + e.Name.Equals(fileName, StringComparison.OrdinalIgnoreCase)); + entry ??= bgEntries.FirstOrDefault(e => + e.FullName.StartsWith($"backgrounds/{index}/", StringComparison.OrdinalIgnoreCase)); + + if (entry == null) return; + + var img = LoadImage(ReadEntry(entry), entry.Name); + if (img == null) return; + + int placement = layer.TryGetProperty("Placement", out var plEl) ? plEl.GetInt32() : 0; + double centerX, centerY, boxW, boxH; + + if (placement == 1) + { + double ppu = BG_HEIGHT / SPACE_UNITS_PER_SCREEN_HEIGHT; + centerX = BG_WIDTH / 2.0 + GetDouble(layer, "SpaceX") * ppu; + centerY = BG_HEIGHT / 2.0 - GetDouble(layer, "SpaceY") * ppu; + boxW = GetDouble(layer, "SpaceWidth", 8) * ppu; + boxH = GetDouble(layer, "SpaceHeight", 8) * ppu; + } + else + { + centerX = GetDouble(layer, "CenterX", 0.5) * BG_WIDTH; + centerY = GetDouble(layer, "CenterY", 0.5) * BG_HEIGHT; + boxW = GetDouble(layer, "ScaleX", 1) * BG_WIDTH; + boxH = GetDouble(layer, "ScaleY", 1) * BG_HEIGHT; + } + + double ratio = Math.Min(boxW / img.GetWidth(), boxH / img.GetHeight()); + int drawW = Math.Max(1, (int)Math.Round(img.GetWidth() * ratio)); + int drawH = Math.Max(1, (int)Math.Round(img.GetHeight() * ratio)); + + var resized = (Image)img.Duplicate(); + resized.Resize(drawW, drawH, Image.Interpolation.Lanczos); + + float tR = (float)(GetDouble(layer, "TintRed", 255) / 255.0); + float tG = (float)(GetDouble(layer, "TintGreen", 255) / 255.0); + float tB = (float)(GetDouble(layer, "TintBlue", 255) / 255.0); + float tA = (float)tintOpacity; + + int dstX = (int)Math.Round(centerX - drawW / 2.0); + int dstY = (int)Math.Round(centerY - drawH / 2.0); + + bool needsTint = tR < 0.999f || tG < 0.999f || tB < 0.999f || tA < 0.999f; + + if (needsTint) + BlendTinted(canvas, resized, dstX, dstY, tR, tG, tB, tA); + else + canvas.BlendRect(resized, new Rect2I(Vector2I.Zero, resized.GetSize()), new Vector2I(dstX, dstY)); + } + + private static void BlendTinted(Image dst, Image src, int dstX, int dstY, + float tR, float tG, float tB, float tA) + { + int srcW = src.GetWidth(), srcH = src.GetHeight(); + int dstW = dst.GetWidth(), dstH = dst.GetHeight(); + + int x0 = Math.Max(0, -dstX); + int y0 = Math.Max(0, -dstY); + int x1 = Math.Min(srcW, dstW - dstX); + int y1 = Math.Min(srcH, dstH - dstY); + + for (int y = y0; y < y1; y++) + { + for (int x = x0; x < x1; x++) + { + var s = src.GetPixel(x, y); + float a = s.A * tA; + if (a <= 0f) continue; + + var d = dst.GetPixel(dstX + x, dstY + y); + float outA = a + d.A * (1f - a); + if (outA <= 0f) continue; + + float inv = d.A * (1f - a); + dst.SetPixel(dstX + x, dstY + y, new Color( + (s.R * tR * a + d.R * inv) / outA, + (s.G * tG * a + d.G * inv) / outA, + (s.B * tB * a + d.B * inv) / outA, + outA)); + } + } + } + + private static void WriteConfig(string skinDir, string colorsetName, string gameSpace) + { + string toml = + $"theme_color = \"c90045\"\n" + + $"note_colors = \"{colorsetName}\"\n" + + $"note_mesh = \"squircle\"\n" + + $"game_space = \"{gameSpace}\"\n" + + $"menu_space = \"waves\"\n"; + File.WriteAllText($"{skinDir}/config.toml", toml); + } + + private static byte[] ReadEntry(ZipArchiveEntry entry) + { + using var s = entry.Open(); + using var ms = new MemoryStream(); + s.CopyTo(ms); + return ms.ToArray(); + } + + private static Image LoadImage(byte[] data, string filename) + { + var image = new Image(); + string ext = Path.GetExtension(filename).ToLowerInvariant(); + + Error err = ext switch + { + ".jpg" or ".jpeg" => image.LoadJpgFromBuffer(data), + ".bmp" => image.LoadBmpFromBuffer(data), + ".webp" => image.LoadWebpFromBuffer(data), + ".tga" => image.LoadTgaFromBuffer(data), + _ => image.LoadPngFromBuffer(data), + }; + + return err == Error.Ok ? image : null; + } + + private static bool IsImageExt(string ext) => ext?.ToLowerInvariant() switch + { + ".png" or ".jpg" or ".jpeg" or ".bmp" or ".webp" or ".tga" or ".gif" => true, + _ => false, + }; + + private static double GetDouble(JsonElement el, string prop, double fallback = 0) + { + return el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.Number + ? v.GetDouble() + : fallback; + } +} From a50e5728cfd1f7d30f999e7f71031c37f2c21354 Mon Sep 17 00:00:00 2001 From: Londopy Date: Sat, 7 Nov 2026 01:06:17 -0800 Subject: [PATCH 2/3] ci: add build-and-release workflow for Windows export --- .github/workflows/build-release.yml | 52 +++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 .github/workflows/build-release.yml diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml new file mode 100644 index 00000000..e3375627 --- /dev/null +++ b/.github/workflows/build-release.yml @@ -0,0 +1,52 @@ +name: Build and Release + +on: + workflow_dispatch: + +jobs: + build-windows: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Setup Godot + uses: chickensoft-games/setup-godot@v2 + with: + version: 4.7.2 + use-dotnet: true + include-templates: true + + - name: Restore dependencies + run: dotnet restore + + - name: Build project + run: dotnet build + + - name: Export Windows + run: | + mkdir -p build/windows + godot --headless --export-release "Windows Desktop" build/windows/Rhythia.zip + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: Rhythia-Windows + path: build/windows/Rhythia.zip + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + tag_name: rhs-import + name: "Rhythia + .rhs Skin Import" + body: | + Modified Rhythia Client with .rhs skin file drag-and-drop support. + + Drop any .rhs skin file onto the game window to import it — extracts cursor, grid, colorset, hit sound, and composites background layers automatically. + files: build/windows/Rhythia.zip + draft: false + prerelease: true From 5bd170ae7cd9de1908d616ed4d55ab0b5e1edcb1 Mon Sep 17 00:00:00 2001 From: Londopy Date: Sat, 7 Nov 2026 01:10:05 -0800 Subject: [PATCH 3/3] fix(ci): add contents write permission for release creation --- .github/workflows/build-release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index e3375627..7a83422d 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -3,6 +3,9 @@ name: Build and Release on: workflow_dispatch: +permissions: + contents: write + jobs: build-windows: runs-on: ubuntu-latest