From 42788834f8873d17d0bd0e64deee05ffb2bd3432 Mon Sep 17 00:00:00 2001
From: Dan Balasescu <smoogipoo@smgi.me>
Date: Fri, 10 Jan 2025 20:28:04 +0900
Subject: [PATCH 01/15] Add LIO service helper

Mostly a copy-paste from
https://github.com/ppy/osu-server-beatmap-submission/blob/82190f3130bae07747fe03a2eadf8cafa81fe401/osu.Server.BeatmapSubmission/Services/LegacyIO.cs,
but supports string responses.
---
 .../Multiplayer/MultiplayerTest.cs            |  6 +-
 .../Multiplayer/TestMultiplayerHub.cs         |  6 +-
 osu.Server.Spectator.sln.DotSettings          |  1 +
 osu.Server.Spectator/AppSettings.cs           | 11 +++
 .../Extensions/ServiceCollectionExtensions.cs | 32 +++---
 .../Hubs/Multiplayer/MultiplayerHub.cs        |  6 +-
 osu.Server.Spectator/Services/ILegacyIO.cs    |  9 ++
 osu.Server.Spectator/Services/LegacyIO.cs     | 98 +++++++++++++++++++
 8 files changed, 151 insertions(+), 18 deletions(-)
 create mode 100644 osu.Server.Spectator/Services/ILegacyIO.cs
 create mode 100644 osu.Server.Spectator/Services/LegacyIO.cs

diff --git a/osu.Server.Spectator.Tests/Multiplayer/MultiplayerTest.cs b/osu.Server.Spectator.Tests/Multiplayer/MultiplayerTest.cs
index 0e774719..38d38def 100644
--- a/osu.Server.Spectator.Tests/Multiplayer/MultiplayerTest.cs
+++ b/osu.Server.Spectator.Tests/Multiplayer/MultiplayerTest.cs
@@ -16,6 +16,7 @@
 using osu.Server.Spectator.Database.Models;
 using osu.Server.Spectator.Entities;
 using osu.Server.Spectator.Hubs.Multiplayer;
+using osu.Server.Spectator.Services;
 
 namespace osu.Server.Spectator.Tests.Multiplayer
 {
@@ -130,13 +131,16 @@ protected MultiplayerTest()
             loggerFactoryMock.Setup(factory => factory.CreateLogger(It.IsAny<string>()))
                              .Returns(new Mock<ILogger>().Object);
 
+            var legacyIOMock = new Mock<ILegacyIO>();
+
             Hub = new TestMultiplayerHub(
                 loggerFactoryMock.Object,
                 Rooms,
                 UserStates,
                 DatabaseFactory.Object,
                 new ChatFilters(DatabaseFactory.Object),
-                hubContext.Object);
+                hubContext.Object,
+                legacyIOMock.Object);
             Hub.Groups = Groups.Object;
             Hub.Clients = Clients.Object;
 
diff --git a/osu.Server.Spectator.Tests/Multiplayer/TestMultiplayerHub.cs b/osu.Server.Spectator.Tests/Multiplayer/TestMultiplayerHub.cs
index 54024987..83d46934 100644
--- a/osu.Server.Spectator.Tests/Multiplayer/TestMultiplayerHub.cs
+++ b/osu.Server.Spectator.Tests/Multiplayer/TestMultiplayerHub.cs
@@ -6,6 +6,7 @@
 using osu.Server.Spectator.Database;
 using osu.Server.Spectator.Entities;
 using osu.Server.Spectator.Hubs.Multiplayer;
+using osu.Server.Spectator.Services;
 
 namespace osu.Server.Spectator.Tests.Multiplayer
 {
@@ -19,8 +20,9 @@ public TestMultiplayerHub(
             EntityStore<MultiplayerClientState> users,
             IDatabaseFactory databaseFactory,
             ChatFilters chatFilters,
-            IHubContext<MultiplayerHub> hubContext)
-            : base(loggerFactory, rooms, users, databaseFactory, chatFilters, hubContext)
+            IHubContext<MultiplayerHub> hubContext,
+            ILegacyIO legacyIO)
+            : base(loggerFactory, rooms, users, databaseFactory, chatFilters, hubContext, legacyIO)
         {
         }
 
diff --git a/osu.Server.Spectator.sln.DotSettings b/osu.Server.Spectator.sln.DotSettings
index 4b60715d..d3b2e19f 100644
--- a/osu.Server.Spectator.sln.DotSettings
+++ b/osu.Server.Spectator.sln.DotSettings
@@ -354,6 +354,7 @@
 	<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=HUD/@EntryIndexedValue">HUD</s:String>
 	<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=ID/@EntryIndexedValue">ID</s:String>
 	<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=IL/@EntryIndexedValue">IL</s:String>
+	<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=IO/@EntryIndexedValue">IO</s:String>
 	<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=IOS/@EntryIndexedValue">IOS</s:String>
 	<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=IP/@EntryIndexedValue">IP</s:String>
 	<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=IPC/@EntryIndexedValue">IPC</s:String>
diff --git a/osu.Server.Spectator/AppSettings.cs b/osu.Server.Spectator/AppSettings.cs
index b8598cdb..17161f27 100644
--- a/osu.Server.Spectator/AppSettings.cs
+++ b/osu.Server.Spectator/AppSettings.cs
@@ -34,6 +34,9 @@ public static class AppSettings
         public static string DatabaseUser { get; }
         public static string DatabasePort { get; }
 
+        public static string LegacyIODomain { get; }
+        public static string SharedInteropSecret { get; }
+
         static AppSettings()
         {
             SaveReplays = Environment.GetEnvironmentVariable("SAVE_REPLAYS") == "1";
@@ -53,6 +56,14 @@ static AppSettings()
             DatabaseHost = Environment.GetEnvironmentVariable("DB_HOST") ?? "localhost";
             DatabaseUser = Environment.GetEnvironmentVariable("DB_USER") ?? "osuweb";
             DatabasePort = Environment.GetEnvironmentVariable("DB_PORT") ?? "3306";
+
+            LegacyIODomain = Environment.GetEnvironmentVariable("LEGACY_IO_DOMAIN")
+                             ?? throw new InvalidOperationException("LEGACY_IO_DOMAIN environment variable not set. "
+                                                                    + "Please set the value of this variable to the root URL of the osu-web instance to which legacy IO call should be submitted.");
+
+            SharedInteropSecret = Environment.GetEnvironmentVariable("SHARED_INTEROP_SECRET")
+                                  ?? throw new InvalidOperationException("SHARED_INTEROP_SECRET environment variable not set. "
+                                                                         + "Please set the value of this variable to the value of the same environment variable that the target osu-web instance specifies in `.env`.");
         }
     }
 }
diff --git a/osu.Server.Spectator/Extensions/ServiceCollectionExtensions.cs b/osu.Server.Spectator/Extensions/ServiceCollectionExtensions.cs
index c1d90a31..84d55414 100644
--- a/osu.Server.Spectator/Extensions/ServiceCollectionExtensions.cs
+++ b/osu.Server.Spectator/Extensions/ServiceCollectionExtensions.cs
@@ -8,6 +8,7 @@
 using osu.Server.Spectator.Hubs.Metadata;
 using osu.Server.Spectator.Hubs.Multiplayer;
 using osu.Server.Spectator.Hubs.Spectator;
+using osu.Server.Spectator.Services;
 using osu.Server.Spectator.Storage;
 using StackExchange.Redis;
 
@@ -17,20 +18,23 @@ public static class ServiceCollectionExtensions
     {
         public static IServiceCollection AddHubEntities(this IServiceCollection serviceCollection)
         {
-            return serviceCollection.AddSingleton<EntityStore<SpectatorClientState>>()
-                                    .AddSingleton<EntityStore<MultiplayerClientState>>()
-                                    .AddSingleton<EntityStore<ServerMultiplayerRoom>>()
-                                    .AddSingleton<EntityStore<ConnectionState>>()
-                                    .AddSingleton<EntityStore<MetadataClientState>>()
-                                    .AddSingleton<GracefulShutdownManager>()
-                                    .AddSingleton<MetadataBroadcaster>()
-                                    .AddSingleton<IScoreStorage, S3ScoreStorage>()
-                                    .AddSingleton<ScoreUploader>()
-                                    .AddSingleton<IScoreProcessedSubscriber, ScoreProcessedSubscriber>()
-                                    .AddSingleton<BuildUserCountUpdater>()
-                                    .AddSingleton<ChatFilters>()
-                                    .AddSingleton<IDailyChallengeUpdater, DailyChallengeUpdater>()
-                                    .AddHostedService<IDailyChallengeUpdater>(ctx => ctx.GetRequiredService<IDailyChallengeUpdater>());
+            return serviceCollection
+                   .AddHttpClient()
+                   .AddTransient<ILegacyIO, LegacyIO>()
+                   .AddSingleton<EntityStore<SpectatorClientState>>()
+                   .AddSingleton<EntityStore<MultiplayerClientState>>()
+                   .AddSingleton<EntityStore<ServerMultiplayerRoom>>()
+                   .AddSingleton<EntityStore<ConnectionState>>()
+                   .AddSingleton<EntityStore<MetadataClientState>>()
+                   .AddSingleton<GracefulShutdownManager>()
+                   .AddSingleton<MetadataBroadcaster>()
+                   .AddSingleton<IScoreStorage, S3ScoreStorage>()
+                   .AddSingleton<ScoreUploader>()
+                   .AddSingleton<IScoreProcessedSubscriber, ScoreProcessedSubscriber>()
+                   .AddSingleton<BuildUserCountUpdater>()
+                   .AddSingleton<ChatFilters>()
+                   .AddSingleton<IDailyChallengeUpdater, DailyChallengeUpdater>()
+                   .AddHostedService<IDailyChallengeUpdater>(ctx => ctx.GetRequiredService<IDailyChallengeUpdater>());
         }
 
         /// <summary>
diff --git a/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs b/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs
index e2d5837e..af7fa745 100644
--- a/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs
+++ b/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs
@@ -16,6 +16,7 @@
 using osu.Server.Spectator.Database.Models;
 using osu.Server.Spectator.Entities;
 using osu.Server.Spectator.Extensions;
+using osu.Server.Spectator.Services;
 
 namespace osu.Server.Spectator.Hubs.Multiplayer
 {
@@ -25,6 +26,7 @@ public class MultiplayerHub : StatefulUserHub<IMultiplayerClient, MultiplayerCli
         protected readonly MultiplayerHubContext HubContext;
         private readonly IDatabaseFactory databaseFactory;
         private readonly ChatFilters chatFilters;
+        private readonly ILegacyIO legacyIO;
 
         public MultiplayerHub(
             ILoggerFactory loggerFactory,
@@ -32,12 +34,14 @@ public MultiplayerHub(
             EntityStore<MultiplayerClientState> users,
             IDatabaseFactory databaseFactory,
             ChatFilters chatFilters,
-            IHubContext<MultiplayerHub> hubContext)
+            IHubContext<MultiplayerHub> hubContext,
+            ILegacyIO legacyIO)
             : base(loggerFactory, users)
         {
             Rooms = rooms;
             this.databaseFactory = databaseFactory;
             this.chatFilters = chatFilters;
+            this.legacyIO = legacyIO;
             HubContext = new MultiplayerHubContext(hubContext, rooms, users, databaseFactory, loggerFactory);
         }
 
diff --git a/osu.Server.Spectator/Services/ILegacyIO.cs b/osu.Server.Spectator/Services/ILegacyIO.cs
new file mode 100644
index 00000000..be57a09e
--- /dev/null
+++ b/osu.Server.Spectator/Services/ILegacyIO.cs
@@ -0,0 +1,9 @@
+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+namespace osu.Server.Spectator.Services
+{
+    public interface ILegacyIO
+    {
+    }
+}
diff --git a/osu.Server.Spectator/Services/LegacyIO.cs b/osu.Server.Spectator/Services/LegacyIO.cs
new file mode 100644
index 00000000..34854ab1
--- /dev/null
+++ b/osu.Server.Spectator/Services/LegacyIO.cs
@@ -0,0 +1,98 @@
+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using System.Linq;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+
+namespace osu.Server.Spectator.Services
+{
+    public class LegacyIO : ILegacyIO
+    {
+        private readonly HttpClient httpClient;
+        private readonly ILogger logger;
+
+        public LegacyIO(HttpClient httpClient, ILoggerFactory loggerFactory)
+        {
+            this.httpClient = httpClient;
+            logger = loggerFactory.CreateLogger("LIO");
+        }
+
+        private async Task<string> runLegacyIO(HttpMethod method, string command, dynamic? postObject = null)
+        {
+            int retryCount = 3;
+
+            retry:
+
+            long time = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
+            string url = $"{AppSettings.LegacyIODomain}/_lio/{command}{(command.Contains('?') ? "&" : "?")}timestamp={time}";
+
+            string? serialisedPostObject = postObject == null ? null : JsonSerializer.Serialize(postObject);
+            logger.LogDebug("Performing LIO request to {method} {url} (params: {params})", method, url, serialisedPostObject);
+
+            try
+            {
+                string signature = hmacEncode(url, Encoding.UTF8.GetBytes(AppSettings.SharedInteropSecret));
+
+                var httpRequestMessage = new HttpRequestMessage
+                {
+                    RequestUri = new Uri(url),
+                    Method = method,
+                    Headers =
+                    {
+                        { "X-LIO-Signature", signature },
+                        { "Accept", "application/json" },
+                    },
+                };
+
+                if (serialisedPostObject != null)
+                {
+                    httpRequestMessage.Content = new ByteArrayContent(Encoding.UTF8.GetBytes(serialisedPostObject));
+                    httpRequestMessage.Content.Headers.ContentType = MediaTypeHeaderValue.Parse("application/json");
+                }
+
+                var response = await httpClient.SendAsync(httpRequestMessage);
+
+                if (!response.IsSuccessStatusCode)
+                    throw new InvalidOperationException($"Legacy IO request to {url} failed with {response.StatusCode} ({response.Content.ReadAsStringAsync().Result})");
+
+                if ((int)response.StatusCode >= 300)
+                    throw new Exception($"Legacy IO request to {url} returned unexpected response {response.StatusCode} ({response.ReasonPhrase})");
+
+                return await response.Content.ReadAsStringAsync();
+            }
+            catch (Exception e)
+            {
+                if (retryCount-- > 0)
+                {
+                    logger.LogError(e, "Legacy IO request to {url} failed, retrying ({retries} remaining)", url, retryCount);
+                    Thread.Sleep(1000);
+                    goto retry;
+                }
+
+                throw;
+            }
+        }
+
+        private static string hmacEncode(string input, byte[] key)
+        {
+            byte[] byteArray = Encoding.ASCII.GetBytes(input);
+
+            using (var hmac = new HMACSHA1(key))
+            {
+                byte[] hashArray = hmac.ComputeHash(byteArray);
+                return hashArray.Aggregate(string.Empty, (s, e) => s + $"{e:x2}", s => s);
+            }
+        }
+
+        // Methods below purposefully async-await on `runLegacyIO()` calls rather than directly returning the underlying calls.
+        // This is done for better readability of exception stacks. Directly returning the tasks elides the name of the proxying method.
+    }
+}

From 9d51555f6fe9bfe2cd0df9499944dea0abd04c92 Mon Sep 17 00:00:00 2001
From: Dan Balasescu <smoogipoo@smgi.me>
Date: Fri, 17 Jan 2025 21:45:44 +0900
Subject: [PATCH 02/15] Add support for creating rooms via LIO

---
 SampleMultiplayerClient/MultiplayerClient.cs  |  5 +++
 .../Hubs/Multiplayer/MultiplayerHub.cs        | 14 +++++++
 osu.Server.Spectator/Services/ILegacyIO.cs    |  4 ++
 osu.Server.Spectator/Services/LegacyIO.cs     | 41 ++++++++++++++++++-
 4 files changed, 63 insertions(+), 1 deletion(-)

diff --git a/SampleMultiplayerClient/MultiplayerClient.cs b/SampleMultiplayerClient/MultiplayerClient.cs
index 026cd8e0..6e47164e 100644
--- a/SampleMultiplayerClient/MultiplayerClient.cs
+++ b/SampleMultiplayerClient/MultiplayerClient.cs
@@ -61,6 +61,11 @@ public MultiplayerClient(HubConnection connection, int userId)
 
         public MultiplayerRoom? Room { get; private set; }
 
+        public Task<MultiplayerRoom> CreateRoom(MultiplayerRoom room)
+        {
+            throw new NotImplementedException();
+        }
+
         public async Task<MultiplayerRoom> JoinRoom(long roomId)
         {
             return await JoinRoomWithPassword(roomId, string.Empty);
diff --git a/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs b/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs
index af7fa745..65ea4468 100644
--- a/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs
+++ b/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs
@@ -45,6 +45,20 @@ public MultiplayerHub(
             HubContext = new MultiplayerHubContext(hubContext, rooms, users, databaseFactory, loggerFactory);
         }
 
+        public async Task<MultiplayerRoom> CreateRoom(MultiplayerRoom room)
+        {
+            Log($"{Context.GetUserId()} creating room");
+
+            try
+            {
+                return await JoinRoomWithPassword(await legacyIO.CreateRoom(Context.GetUserId(), room), room.Settings.Password);
+            }
+            catch (Exception ex)
+            {
+                throw new InvalidStateException($"Failed to create the multiplayer room ({ex.Message}).");
+            }
+        }
+
         public Task<MultiplayerRoom> JoinRoom(long roomId) => JoinRoomWithPassword(roomId, string.Empty);
 
         public async Task<MultiplayerRoom> JoinRoomWithPassword(long roomId, string password)
diff --git a/osu.Server.Spectator/Services/ILegacyIO.cs b/osu.Server.Spectator/Services/ILegacyIO.cs
index be57a09e..1e21d669 100644
--- a/osu.Server.Spectator/Services/ILegacyIO.cs
+++ b/osu.Server.Spectator/Services/ILegacyIO.cs
@@ -1,9 +1,13 @@
 // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
 // See the LICENCE file in the repository root for full licence text.
 
+using System.Threading.Tasks;
+using osu.Game.Online.Multiplayer;
+
 namespace osu.Server.Spectator.Services
 {
     public interface ILegacyIO
     {
+        Task<long> CreateRoom(int userId, MultiplayerRoom room);
     }
 }
diff --git a/osu.Server.Spectator/Services/LegacyIO.cs b/osu.Server.Spectator/Services/LegacyIO.cs
index 34854ab1..7e4ebd13 100644
--- a/osu.Server.Spectator/Services/LegacyIO.cs
+++ b/osu.Server.Spectator/Services/LegacyIO.cs
@@ -11,6 +11,8 @@
 using System.Threading;
 using System.Threading.Tasks;
 using Microsoft.Extensions.Logging;
+using osu.Game.Online.Multiplayer;
+using osu.Game.Online.Rooms;
 
 namespace osu.Server.Spectator.Services
 {
@@ -34,7 +36,13 @@ private async Task<string> runLegacyIO(HttpMethod method, string command, dynami
             long time = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
             string url = $"{AppSettings.LegacyIODomain}/_lio/{command}{(command.Contains('?') ? "&" : "?")}timestamp={time}";
 
-            string? serialisedPostObject = postObject == null ? null : JsonSerializer.Serialize(postObject);
+            string? serialisedPostObject = postObject switch
+            {
+                null => null,
+                string => postObject,
+                _ => JsonSerializer.Serialize(postObject)
+            };
+
             logger.LogDebug("Performing LIO request to {method} {url} (params: {params})", method, url, serialisedPostObject);
 
             try
@@ -94,5 +102,36 @@ private static string hmacEncode(string input, byte[] key)
 
         // Methods below purposefully async-await on `runLegacyIO()` calls rather than directly returning the underlying calls.
         // This is done for better readability of exception stacks. Directly returning the tasks elides the name of the proxying method.
+
+        public async Task<long> CreateRoom(int userId, MultiplayerRoom room)
+        {
+            return long.Parse(await runLegacyIO(HttpMethod.Post, "multiplayer/rooms", Newtonsoft.Json.JsonConvert.SerializeObject(new CreateRoomRequest(room)
+            {
+                UserId = userId
+            })));
+        }
+
+        private class CreateRoomRequest : Room
+        {
+            [Newtonsoft.Json.JsonProperty("user_id")]
+            public required int UserId { get; init; }
+
+            /// <summary>
+            /// Creates a <see cref="Room"/> from a <see cref="MultiplayerRoom"/>.
+            /// </summary>
+            public CreateRoomRequest(MultiplayerRoom room)
+            {
+                RoomID = room.RoomID;
+                Host = room.Host?.User;
+                Name = room.Settings.Name;
+                Password = room.Settings.Password;
+                Type = room.Settings.MatchType;
+                QueueMode = room.Settings.QueueMode;
+                AutoStartDuration = room.Settings.AutoStartDuration;
+                AutoSkip = room.Settings.AutoSkip;
+                Playlist = room.Playlist.Select(item => new PlaylistItem(item)).ToArray();
+                CurrentPlaylistItem = Playlist.FirstOrDefault(item => item.ID == room.Settings.PlaylistItemId);
+            }
+        }
     }
 }

From 1ee9712f21a1c3f0821c42c4be492026b6108c99 Mon Sep 17 00:00:00 2001
From: Dan Balasescu <smoogipoo@smgi.me>
Date: Wed, 22 Jan 2025 21:43:21 +0900
Subject: [PATCH 03/15] Add support for joining/parting rooms via LIO

---
 .../Hubs/Multiplayer/MultiplayerHub.cs                | 11 +++++++----
 osu.Server.Spectator/Services/ILegacyIO.cs            |  4 ++++
 osu.Server.Spectator/Services/LegacyIO.cs             | 10 ++++++++++
 3 files changed, 21 insertions(+), 4 deletions(-)

diff --git a/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs b/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs
index 65ea4468..13abe669 100644
--- a/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs
+++ b/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs
@@ -65,12 +65,13 @@ public async Task<MultiplayerRoom> JoinRoomWithPassword(long roomId, string pass
         {
             Log($"Attempting to join room {roomId}");
 
-            bool isRestricted;
             using (var db = databaseFactory.GetInstance())
-                isRestricted = await db.IsUserRestrictedAsync(Context.GetUserId());
+            {
+                if (await db.IsUserRestrictedAsync(Context.GetUserId()))
+                    throw new InvalidStateException("Can't join a room when restricted.");
+            }
 
-            if (isRestricted)
-                throw new InvalidStateException("Can't join a room when restricted.");
+            await legacyIO.JoinRoom(roomId, Context.GetUserId());
 
             using (var userUsage = await GetOrCreateLocalUserState())
             {
@@ -901,6 +902,8 @@ private async Task<ItemUsage<ServerMultiplayerRoom>> getLocalUserRoom(Multiplaye
 
         private async Task leaveRoom(MultiplayerClientState state, bool wasKick)
         {
+            await legacyIO.PartRoom(state.CurrentRoomID, state.UserId);
+
             using (var roomUsage = await getLocalUserRoom(state))
                 await leaveRoom(state, roomUsage, wasKick);
         }
diff --git a/osu.Server.Spectator/Services/ILegacyIO.cs b/osu.Server.Spectator/Services/ILegacyIO.cs
index 1e21d669..9877109f 100644
--- a/osu.Server.Spectator/Services/ILegacyIO.cs
+++ b/osu.Server.Spectator/Services/ILegacyIO.cs
@@ -9,5 +9,9 @@ namespace osu.Server.Spectator.Services
     public interface ILegacyIO
     {
         Task<long> CreateRoom(int userId, MultiplayerRoom room);
+
+        Task JoinRoom(long roomId, int userId);
+
+        Task PartRoom(long roomId, int userId);
     }
 }
diff --git a/osu.Server.Spectator/Services/LegacyIO.cs b/osu.Server.Spectator/Services/LegacyIO.cs
index 7e4ebd13..8fe91c15 100644
--- a/osu.Server.Spectator/Services/LegacyIO.cs
+++ b/osu.Server.Spectator/Services/LegacyIO.cs
@@ -111,6 +111,16 @@ public async Task<long> CreateRoom(int userId, MultiplayerRoom room)
             })));
         }
 
+        public async Task JoinRoom(long roomId, int userId)
+        {
+            await runLegacyIO(HttpMethod.Put, $"multiplayer/rooms/{roomId}/users/{userId}");
+        }
+
+        public async Task PartRoom(long roomId, int userId)
+        {
+            await runLegacyIO(HttpMethod.Delete, $"multiplayer/rooms/{roomId}/users/{userId}");
+        }
+
         private class CreateRoomRequest : Room
         {
             [Newtonsoft.Json.JsonProperty("user_id")]

From b5b5a9f4db1856581b4051e97268e1bbbf8328d9 Mon Sep 17 00:00:00 2001
From: Dan Balasescu <smoogipoo@smgi.me>
Date: Thu, 23 Jan 2025 18:39:51 +0900
Subject: [PATCH 04/15] Adjust exception handling for hub usage

Avoid serialising raw details about the exception (e.g. URL) and unwrap
`{ "error": "" }` JSON responses, for instance as returned by the room
creation endpoint.

Client receives:
- `APP_DEBUG=true` -> "422: Unprocessable Entity" (HTTP status code msg)
- `APP_DEBUG=false` -> "beatmaps not found: ..." (osu!web API error msg)
---
 .../Hubs/Multiplayer/MultiplayerHub.cs        | 12 +----
 osu.Server.Spectator/Services/LegacyIO.cs     | 45 ++++++++++++++++---
 2 files changed, 41 insertions(+), 16 deletions(-)

diff --git a/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs b/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs
index 13abe669..40367e1e 100644
--- a/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs
+++ b/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs
@@ -48,15 +48,7 @@ public MultiplayerHub(
         public async Task<MultiplayerRoom> CreateRoom(MultiplayerRoom room)
         {
             Log($"{Context.GetUserId()} creating room");
-
-            try
-            {
-                return await JoinRoomWithPassword(await legacyIO.CreateRoom(Context.GetUserId(), room), room.Settings.Password);
-            }
-            catch (Exception ex)
-            {
-                throw new InvalidStateException($"Failed to create the multiplayer room ({ex.Message}).");
-            }
+            return await JoinRoomWithPassword(await legacyIO.CreateRoom(Context.GetUserId(), room), room.Settings.Password);
         }
 
         public Task<MultiplayerRoom> JoinRoom(long roomId) => JoinRoomWithPassword(roomId, string.Empty);
@@ -739,8 +731,8 @@ private async Task removeDatabaseUser(MultiplayerRoom room, MultiplayerRoomUser
 
         protected override async Task CleanUpState(MultiplayerClientState state)
         {
-            await leaveRoom(state, true);
             await base.CleanUpState(state);
+            await leaveRoom(state, true);
         }
 
         private async Task setNewHost(MultiplayerRoom room, MultiplayerRoomUser newHost)
diff --git a/osu.Server.Spectator/Services/LegacyIO.cs b/osu.Server.Spectator/Services/LegacyIO.cs
index 8fe91c15..10c122cf 100644
--- a/osu.Server.Spectator/Services/LegacyIO.cs
+++ b/osu.Server.Spectator/Services/LegacyIO.cs
@@ -8,8 +8,10 @@
 using System.Security.Cryptography;
 using System.Text;
 using System.Text.Json;
+using System.Text.Json.Serialization;
 using System.Threading;
 using System.Threading.Tasks;
+using Microsoft.AspNetCore.SignalR;
 using Microsoft.Extensions.Logging;
 using osu.Game.Online.Multiplayer;
 using osu.Game.Online.Rooms;
@@ -68,13 +70,10 @@ private async Task<string> runLegacyIO(HttpMethod method, string command, dynami
 
                 var response = await httpClient.SendAsync(httpRequestMessage);
 
-                if (!response.IsSuccessStatusCode)
-                    throw new InvalidOperationException($"Legacy IO request to {url} failed with {response.StatusCode} ({response.Content.ReadAsStringAsync().Result})");
+                if (response.IsSuccessStatusCode)
+                    return await response.Content.ReadAsStringAsync();
 
-                if ((int)response.StatusCode >= 300)
-                    throw new Exception($"Legacy IO request to {url} returned unexpected response {response.StatusCode} ({response.ReasonPhrase})");
-
-                return await response.Content.ReadAsStringAsync();
+                throw await LegacyIORequestFailedException.Create(url, response);
             }
             catch (Exception e)
             {
@@ -143,5 +142,39 @@ public CreateRoomRequest(MultiplayerRoom room)
                 CurrentPlaylistItem = Playlist.FirstOrDefault(item => item.ID == room.Settings.PlaylistItemId);
             }
         }
+
+        [Serializable]
+        private class LegacyIORequestFailedException : HubException
+        {
+            private LegacyIORequestFailedException(string message, Exception innerException)
+                : base(message, innerException)
+            {
+            }
+
+            public static async Task<LegacyIORequestFailedException> Create(string url, HttpResponseMessage response)
+            {
+                string errorMessage = $"{(int)response.StatusCode}: {response.ReasonPhrase}";
+
+                try
+                {
+                    APIErrorMessage? apiError = await JsonSerializer.DeserializeAsync<APIErrorMessage>(await response.Content.ReadAsStreamAsync().ConfigureAwait(false));
+                    if (!string.IsNullOrEmpty(apiError?.Error))
+                        errorMessage = apiError.Error;
+                }
+                catch
+                {
+                }
+
+                // Outer exception message is serialised to clients, inner exception is logged to the server and NOT serialised to the client.
+                return new LegacyIORequestFailedException(errorMessage, new Exception($"Legacy IO request to {url} failed with {response.StatusCode} ({response.ReasonPhrase})."));
+            }
+
+            [Serializable]
+            private class APIErrorMessage
+            {
+                [JsonPropertyName("error")]
+                public string Error { get; set; } = string.Empty;
+            }
+        }
     }
 }

From 76bd08483781d6f7b92892005ec4c0b9d15a5aee Mon Sep 17 00:00:00 2001
From: Dan Balasescu <smoogipoo@smgi.me>
Date: Thu, 23 Jan 2025 19:30:29 +0900
Subject: [PATCH 05/15] Refactorings and cleanups

---
 SampleMultiplayerClient/MultiplayerClient.cs  | 12 ++-----
 .../Extensions/ServiceCollectionExtensions.cs | 33 +++++++++----------
 osu.Server.Spectator/Services/ILegacyIO.cs    | 16 +++++++++
 3 files changed, 35 insertions(+), 26 deletions(-)

diff --git a/SampleMultiplayerClient/MultiplayerClient.cs b/SampleMultiplayerClient/MultiplayerClient.cs
index 6e47164e..c3c86d58 100644
--- a/SampleMultiplayerClient/MultiplayerClient.cs
+++ b/SampleMultiplayerClient/MultiplayerClient.cs
@@ -62,19 +62,13 @@ public MultiplayerClient(HubConnection connection, int userId)
         public MultiplayerRoom? Room { get; private set; }
 
         public Task<MultiplayerRoom> CreateRoom(MultiplayerRoom room)
-        {
-            throw new NotImplementedException();
-        }
+            => connection.InvokeAsync<MultiplayerRoom>(nameof(IMultiplayerServer.CreateRoom), room);
 
         public async Task<MultiplayerRoom> JoinRoom(long roomId)
-        {
-            return await JoinRoomWithPassword(roomId, string.Empty);
-        }
+            => await JoinRoomWithPassword(roomId, string.Empty);
 
         public async Task<MultiplayerRoom> JoinRoomWithPassword(long roomId, string? password = null)
-        {
-            return Room = await connection.InvokeAsync<MultiplayerRoom>(nameof(IMultiplayerServer.JoinRoomWithPassword), roomId, password ?? string.Empty);
-        }
+            => Room = await connection.InvokeAsync<MultiplayerRoom>(nameof(IMultiplayerServer.JoinRoomWithPassword), roomId, password ?? string.Empty);
 
         public async Task LeaveRoom()
         {
diff --git a/osu.Server.Spectator/Extensions/ServiceCollectionExtensions.cs b/osu.Server.Spectator/Extensions/ServiceCollectionExtensions.cs
index 84d55414..d5243813 100644
--- a/osu.Server.Spectator/Extensions/ServiceCollectionExtensions.cs
+++ b/osu.Server.Spectator/Extensions/ServiceCollectionExtensions.cs
@@ -18,23 +18,22 @@ public static class ServiceCollectionExtensions
     {
         public static IServiceCollection AddHubEntities(this IServiceCollection serviceCollection)
         {
-            return serviceCollection
-                   .AddHttpClient()
-                   .AddTransient<ILegacyIO, LegacyIO>()
-                   .AddSingleton<EntityStore<SpectatorClientState>>()
-                   .AddSingleton<EntityStore<MultiplayerClientState>>()
-                   .AddSingleton<EntityStore<ServerMultiplayerRoom>>()
-                   .AddSingleton<EntityStore<ConnectionState>>()
-                   .AddSingleton<EntityStore<MetadataClientState>>()
-                   .AddSingleton<GracefulShutdownManager>()
-                   .AddSingleton<MetadataBroadcaster>()
-                   .AddSingleton<IScoreStorage, S3ScoreStorage>()
-                   .AddSingleton<ScoreUploader>()
-                   .AddSingleton<IScoreProcessedSubscriber, ScoreProcessedSubscriber>()
-                   .AddSingleton<BuildUserCountUpdater>()
-                   .AddSingleton<ChatFilters>()
-                   .AddSingleton<IDailyChallengeUpdater, DailyChallengeUpdater>()
-                   .AddHostedService<IDailyChallengeUpdater>(ctx => ctx.GetRequiredService<IDailyChallengeUpdater>());
+            return serviceCollection.AddHttpClient()
+                                    .AddTransient<ILegacyIO, LegacyIO>()
+                                    .AddSingleton<EntityStore<SpectatorClientState>>()
+                                    .AddSingleton<EntityStore<MultiplayerClientState>>()
+                                    .AddSingleton<EntityStore<ServerMultiplayerRoom>>()
+                                    .AddSingleton<EntityStore<ConnectionState>>()
+                                    .AddSingleton<EntityStore<MetadataClientState>>()
+                                    .AddSingleton<GracefulShutdownManager>()
+                                    .AddSingleton<MetadataBroadcaster>()
+                                    .AddSingleton<IScoreStorage, S3ScoreStorage>()
+                                    .AddSingleton<ScoreUploader>()
+                                    .AddSingleton<IScoreProcessedSubscriber, ScoreProcessedSubscriber>()
+                                    .AddSingleton<BuildUserCountUpdater>()
+                                    .AddSingleton<ChatFilters>()
+                                    .AddSingleton<IDailyChallengeUpdater, DailyChallengeUpdater>()
+                                    .AddHostedService<IDailyChallengeUpdater>(ctx => ctx.GetRequiredService<IDailyChallengeUpdater>());
         }
 
         /// <summary>
diff --git a/osu.Server.Spectator/Services/ILegacyIO.cs b/osu.Server.Spectator/Services/ILegacyIO.cs
index 9877109f..284fa76d 100644
--- a/osu.Server.Spectator/Services/ILegacyIO.cs
+++ b/osu.Server.Spectator/Services/ILegacyIO.cs
@@ -8,10 +8,26 @@ namespace osu.Server.Spectator.Services
 {
     public interface ILegacyIO
     {
+        /// <summary>
+        /// Creates an osu!web Room.
+        /// </summary>
+        /// <param name="userId">The ID of the user that wants to create the room.</param>
+        /// <param name="room">The room.</param>
+        /// <returns>The room's ID.</returns>
         Task<long> CreateRoom(int userId, MultiplayerRoom room);
 
+        /// <summary>
+        /// Joins an osu!web Room.
+        /// </summary>
+        /// <param name="roomId">The ID of the room to join.</param>
+        /// <param name="userId">The ID of the user wanting to join the room.</param>
         Task JoinRoom(long roomId, int userId);
 
+        /// <summary>
+        /// Parts an osu!web Room.
+        /// </summary>
+        /// <param name="roomId">The ID of the room to part.</param>
+        /// <param name="userId">The ID of the user wanting to part the room.</param>
         Task PartRoom(long roomId, int userId);
     }
 }

From 39bdadab5374fb7fdf1d8cf0f5f7e9e091eabc02 Mon Sep 17 00:00:00 2001
From: Dan Balasescu <smoogipoo@smgi.me>
Date: Sat, 25 Jan 2025 19:36:10 +0900
Subject: [PATCH 06/15] Add "Async" suffix

---
 osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs | 6 +++---
 osu.Server.Spectator/Services/ILegacyIO.cs              | 6 +++---
 osu.Server.Spectator/Services/LegacyIO.cs               | 6 +++---
 3 files changed, 9 insertions(+), 9 deletions(-)

diff --git a/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs b/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs
index 40367e1e..e36eefb9 100644
--- a/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs
+++ b/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs
@@ -48,7 +48,7 @@ public MultiplayerHub(
         public async Task<MultiplayerRoom> CreateRoom(MultiplayerRoom room)
         {
             Log($"{Context.GetUserId()} creating room");
-            return await JoinRoomWithPassword(await legacyIO.CreateRoom(Context.GetUserId(), room), room.Settings.Password);
+            return await JoinRoomWithPassword(await legacyIO.CreateRoomAsync(Context.GetUserId(), room), room.Settings.Password);
         }
 
         public Task<MultiplayerRoom> JoinRoom(long roomId) => JoinRoomWithPassword(roomId, string.Empty);
@@ -63,7 +63,7 @@ public async Task<MultiplayerRoom> JoinRoomWithPassword(long roomId, string pass
                     throw new InvalidStateException("Can't join a room when restricted.");
             }
 
-            await legacyIO.JoinRoom(roomId, Context.GetUserId());
+            await legacyIO.JoinRoomAsync(roomId, Context.GetUserId());
 
             using (var userUsage = await GetOrCreateLocalUserState())
             {
@@ -894,7 +894,7 @@ private async Task<ItemUsage<ServerMultiplayerRoom>> getLocalUserRoom(Multiplaye
 
         private async Task leaveRoom(MultiplayerClientState state, bool wasKick)
         {
-            await legacyIO.PartRoom(state.CurrentRoomID, state.UserId);
+            await legacyIO.PartRoomAsync(state.CurrentRoomID, state.UserId);
 
             using (var roomUsage = await getLocalUserRoom(state))
                 await leaveRoom(state, roomUsage, wasKick);
diff --git a/osu.Server.Spectator/Services/ILegacyIO.cs b/osu.Server.Spectator/Services/ILegacyIO.cs
index 284fa76d..7741583a 100644
--- a/osu.Server.Spectator/Services/ILegacyIO.cs
+++ b/osu.Server.Spectator/Services/ILegacyIO.cs
@@ -14,20 +14,20 @@ public interface ILegacyIO
         /// <param name="userId">The ID of the user that wants to create the room.</param>
         /// <param name="room">The room.</param>
         /// <returns>The room's ID.</returns>
-        Task<long> CreateRoom(int userId, MultiplayerRoom room);
+        Task<long> CreateRoomAsync(int userId, MultiplayerRoom room);
 
         /// <summary>
         /// Joins an osu!web Room.
         /// </summary>
         /// <param name="roomId">The ID of the room to join.</param>
         /// <param name="userId">The ID of the user wanting to join the room.</param>
-        Task JoinRoom(long roomId, int userId);
+        Task JoinRoomAsync(long roomId, int userId);
 
         /// <summary>
         /// Parts an osu!web Room.
         /// </summary>
         /// <param name="roomId">The ID of the room to part.</param>
         /// <param name="userId">The ID of the user wanting to part the room.</param>
-        Task PartRoom(long roomId, int userId);
+        Task PartRoomAsync(long roomId, int userId);
     }
 }
diff --git a/osu.Server.Spectator/Services/LegacyIO.cs b/osu.Server.Spectator/Services/LegacyIO.cs
index 10c122cf..f56910f6 100644
--- a/osu.Server.Spectator/Services/LegacyIO.cs
+++ b/osu.Server.Spectator/Services/LegacyIO.cs
@@ -102,7 +102,7 @@ private static string hmacEncode(string input, byte[] key)
         // Methods below purposefully async-await on `runLegacyIO()` calls rather than directly returning the underlying calls.
         // This is done for better readability of exception stacks. Directly returning the tasks elides the name of the proxying method.
 
-        public async Task<long> CreateRoom(int userId, MultiplayerRoom room)
+        public async Task<long> CreateRoomAsync(int userId, MultiplayerRoom room)
         {
             return long.Parse(await runLegacyIO(HttpMethod.Post, "multiplayer/rooms", Newtonsoft.Json.JsonConvert.SerializeObject(new CreateRoomRequest(room)
             {
@@ -110,12 +110,12 @@ public async Task<long> CreateRoom(int userId, MultiplayerRoom room)
             })));
         }
 
-        public async Task JoinRoom(long roomId, int userId)
+        public async Task JoinRoomAsync(long roomId, int userId)
         {
             await runLegacyIO(HttpMethod.Put, $"multiplayer/rooms/{roomId}/users/{userId}");
         }
 
-        public async Task PartRoom(long roomId, int userId)
+        public async Task PartRoomAsync(long roomId, int userId)
         {
             await runLegacyIO(HttpMethod.Delete, $"multiplayer/rooms/{roomId}/users/{userId}");
         }

From 5288875e7bee8781b34ebccba96255d7698071fe Mon Sep 17 00:00:00 2001
From: Dan Balasescu <smoogipoo@smgi.me>
Date: Sat, 25 Jan 2025 19:39:06 +0900
Subject: [PATCH 07/15] Add env vars to readme

---
 README.md | 32 +++++++++++++++++---------------
 1 file changed, 17 insertions(+), 15 deletions(-)

diff --git a/README.md b/README.md
index 36788894..c56c8e18 100644
--- a/README.md
+++ b/README.md
@@ -10,18 +10,20 @@ To deploy this as part of a full osu! server stack deployment, [this wiki page](
 
 For advanced testing purposes.
 
-| Envvar name | Description | Default value |
-| :- | :- | :- |
-| `SAVE_REPLAYS` | Whether to save received replay frames from clients to replay files. `1` to enable, any other value to disable. | `""` (disabled) |
-| `REPLAY_UPLOAD_THREADS` | Number of threads to use when uploading complete replays. Must be positive number. | `1` |
-| `REPLAYS_PATH` | Local path to store complete replay files (`.osr`) to. Only used if [`FileScoreStorage`](https://github.com/ppy/osu-server-spectator/blob/master/osu.Server.Spectator/Storage/FileScoreStorage.cs) is active. | `./replays/` |
-| `S3_KEY` | An access key ID to use for uploading replays to [AWS S3](https://aws.amazon.com/s3/). Only used if [`S3ScoreStorage`](https://github.com/ppy/osu-server-spectator/blob/master/osu.Server.Spectator/Storage/S3ScoreStorage.cs) is active. | `""` |
-| `S3_SECRET` | The secret access key to use for uploading replays to [AWS S3](https://aws.amazon.com/s3/). Only used if [`S3ScoreStorage`](https://github.com/ppy/osu-server-spectator/blob/master/osu.Server.Spectator/Storage/S3ScoreStorage.cs) is active. | `""` |
-| `REPLAYS_BUCKET` | The name of the [AWS S3](https://aws.amazon.com/s3/) bucket to upload replays to. Only used if [`S3ScoreStorage`](https://github.com/ppy/osu-server-spectator/blob/master/osu.Server.Spectator/Storage/S3ScoreStorage.cs) is active. | `""` |
-| `TRACK_BUILD_USER_COUNTS` | Whether to track how many users are on a particular build of the game and report that information to the database at `DB_{HOST,PORT}`. `1` to enable, any other value to disable. | `""` (disabled) |
-| `SERVER_PORT` | Which port the server should listen on for incoming connections. | `80` |
-| `REDIS_HOST` | Connection string to `osu-web` Redis instance. | `localhost` |
-| `DD_AGENT_HOST` | Hostname under which the [Datadog](https://www.datadoghq.com/) agent host can be found. | `localhost` |
-| `DB_HOST` | Hostname under which the `osu-web` MySQL instance can be found. | `localhost` |
-| `DB_PORT` | Port under which the `osu-web` MySQL instance can be found. | `3306` |
-| `DB_USER` | Username to use when logging into the `osu-web` MySQL instance. | `osuweb` |
+| Envvar name | Description | Default value     |
+| :- | :- |:------------------|
+| `SAVE_REPLAYS` | Whether to save received replay frames from clients to replay files. `1` to enable, any other value to disable. | `""` (disabled)   |
+| `REPLAY_UPLOAD_THREADS` | Number of threads to use when uploading complete replays. Must be positive number. | `1`               |
+| `REPLAYS_PATH` | Local path to store complete replay files (`.osr`) to. Only used if [`FileScoreStorage`](https://github.com/ppy/osu-server-spectator/blob/master/osu.Server.Spectator/Storage/FileScoreStorage.cs) is active. | `./replays/`      |
+| `S3_KEY` | An access key ID to use for uploading replays to [AWS S3](https://aws.amazon.com/s3/). Only used if [`S3ScoreStorage`](https://github.com/ppy/osu-server-spectator/blob/master/osu.Server.Spectator/Storage/S3ScoreStorage.cs) is active. | `""`              |
+| `S3_SECRET` | The secret access key to use for uploading replays to [AWS S3](https://aws.amazon.com/s3/). Only used if [`S3ScoreStorage`](https://github.com/ppy/osu-server-spectator/blob/master/osu.Server.Spectator/Storage/S3ScoreStorage.cs) is active. | `""`              |
+| `REPLAYS_BUCKET` | The name of the [AWS S3](https://aws.amazon.com/s3/) bucket to upload replays to. Only used if [`S3ScoreStorage`](https://github.com/ppy/osu-server-spectator/blob/master/osu.Server.Spectator/Storage/S3ScoreStorage.cs) is active. | `""`              |
+| `TRACK_BUILD_USER_COUNTS` | Whether to track how many users are on a particular build of the game and report that information to the database at `DB_{HOST,PORT}`. `1` to enable, any other value to disable. | `""` (disabled)   |
+| `SERVER_PORT` | Which port the server should listen on for incoming connections. | `80`              |
+| `REDIS_HOST` | Connection string to `osu-web` Redis instance. | `localhost`       |
+| `DD_AGENT_HOST` | Hostname under which the [Datadog](https://www.datadoghq.com/) agent host can be found. | `localhost`       |
+| `DB_HOST` | Hostname under which the `osu-web` MySQL instance can be found. | `localhost`       |
+| `DB_PORT` | Port under which the `osu-web` MySQL instance can be found. | `3306`            |
+| `DB_USER` | Username to use when logging into the `osu-web` MySQL instance. | `osuweb`          |
+| `LEGACY_IO_DOMAIN` | The root URL of the osu-web instance to which legacy IO call should be submitted | `null` (required) |
+| `SHARED_INTEROP_SECRET` | The value of the same environment variable that the target osu-web instance specifies in `.env`. | `null` (required) |
\ No newline at end of file

From 977d127e9bd9e732b711d3e07bae23651361f6dd Mon Sep 17 00:00:00 2001
From: Dan Balasescu <smoogipoo@smgi.me>
Date: Mon, 27 Jan 2025 14:08:03 +0900
Subject: [PATCH 08/15] Clean up hashing

Co-authored-by: Berkan Diler <berkan.diler1@ingka.ikea.com>
---
 osu.Server.Spectator/Services/LegacyIO.cs | 7 ++-----
 1 file changed, 2 insertions(+), 5 deletions(-)

diff --git a/osu.Server.Spectator/Services/LegacyIO.cs b/osu.Server.Spectator/Services/LegacyIO.cs
index f56910f6..228a1150 100644
--- a/osu.Server.Spectator/Services/LegacyIO.cs
+++ b/osu.Server.Spectator/Services/LegacyIO.cs
@@ -92,11 +92,8 @@ private static string hmacEncode(string input, byte[] key)
         {
             byte[] byteArray = Encoding.ASCII.GetBytes(input);
 
-            using (var hmac = new HMACSHA1(key))
-            {
-                byte[] hashArray = hmac.ComputeHash(byteArray);
-                return hashArray.Aggregate(string.Empty, (s, e) => s + $"{e:x2}", s => s);
-            }
+            byte[] hashArray = HMACSHA1.HashData(key, byteArray);
+            return hashArray.Aggregate(string.Empty, (s, e) => s + $"{e:x2}", s => s);
         }
 
         // Methods below purposefully async-await on `runLegacyIO()` calls rather than directly returning the underlying calls.

From 8e46fabb3511f5220c39d4bc7c4faa1d6001b7de Mon Sep 17 00:00:00 2001
From: Dan Balasescu <smoogipoo@smgi.me>
Date: Mon, 27 Jan 2025 17:07:22 +0900
Subject: [PATCH 09/15] Fix tests

---
 osu.Server.Spectator/AppSettings.cs       |  9 ++-------
 osu.Server.Spectator/Services/LegacyIO.cs | 14 ++++++++++++--
 2 files changed, 14 insertions(+), 9 deletions(-)

diff --git a/osu.Server.Spectator/AppSettings.cs b/osu.Server.Spectator/AppSettings.cs
index 17161f27..f16325db 100644
--- a/osu.Server.Spectator/AppSettings.cs
+++ b/osu.Server.Spectator/AppSettings.cs
@@ -57,13 +57,8 @@ static AppSettings()
             DatabaseUser = Environment.GetEnvironmentVariable("DB_USER") ?? "osuweb";
             DatabasePort = Environment.GetEnvironmentVariable("DB_PORT") ?? "3306";
 
-            LegacyIODomain = Environment.GetEnvironmentVariable("LEGACY_IO_DOMAIN")
-                             ?? throw new InvalidOperationException("LEGACY_IO_DOMAIN environment variable not set. "
-                                                                    + "Please set the value of this variable to the root URL of the osu-web instance to which legacy IO call should be submitted.");
-
-            SharedInteropSecret = Environment.GetEnvironmentVariable("SHARED_INTEROP_SECRET")
-                                  ?? throw new InvalidOperationException("SHARED_INTEROP_SECRET environment variable not set. "
-                                                                         + "Please set the value of this variable to the value of the same environment variable that the target osu-web instance specifies in `.env`.");
+            LegacyIODomain = Environment.GetEnvironmentVariable("LEGACY_IO_DOMAIN") ?? string.Empty;
+            SharedInteropSecret = Environment.GetEnvironmentVariable("SHARED_INTEROP_SECRET") ?? string.Empty;
         }
     }
 }
diff --git a/osu.Server.Spectator/Services/LegacyIO.cs b/osu.Server.Spectator/Services/LegacyIO.cs
index 228a1150..cb166d49 100644
--- a/osu.Server.Spectator/Services/LegacyIO.cs
+++ b/osu.Server.Spectator/Services/LegacyIO.cs
@@ -23,10 +23,20 @@ public class LegacyIO : ILegacyIO
         private readonly HttpClient httpClient;
         private readonly ILogger logger;
 
+        private readonly string interopDomain;
+        private readonly string interopSecret;
+
         public LegacyIO(HttpClient httpClient, ILoggerFactory loggerFactory)
         {
             this.httpClient = httpClient;
             logger = loggerFactory.CreateLogger("LIO");
+
+            interopDomain = AppSettings.LegacyIODomain
+                            ?? throw new InvalidOperationException("LEGACY_IO_DOMAIN environment variable not set. "
+                                                                   + "Please set the value of this variable to the root URL of the osu-web instance to which legacy IO call should be submitted.");
+            interopSecret = AppSettings.SharedInteropSecret
+                            ?? throw new InvalidOperationException("SHARED_INTEROP_SECRET environment variable not set. "
+                                                                   + "Please set the value of this variable to the value of the same environment variable that the target osu-web instance specifies in `.env`.");
         }
 
         private async Task<string> runLegacyIO(HttpMethod method, string command, dynamic? postObject = null)
@@ -36,7 +46,7 @@ private async Task<string> runLegacyIO(HttpMethod method, string command, dynami
             retry:
 
             long time = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
-            string url = $"{AppSettings.LegacyIODomain}/_lio/{command}{(command.Contains('?') ? "&" : "?")}timestamp={time}";
+            string url = $"{interopDomain}/_lio/{command}{(command.Contains('?') ? "&" : "?")}timestamp={time}";
 
             string? serialisedPostObject = postObject switch
             {
@@ -49,7 +59,7 @@ private async Task<string> runLegacyIO(HttpMethod method, string command, dynami
 
             try
             {
-                string signature = hmacEncode(url, Encoding.UTF8.GetBytes(AppSettings.SharedInteropSecret));
+                string signature = hmacEncode(url, Encoding.UTF8.GetBytes(interopSecret));
 
                 var httpRequestMessage = new HttpRequestMessage
                 {

From 32a591f2ced8124ef4124450514752dddb0d1953 Mon Sep 17 00:00:00 2001
From: Dan Balasescu <smoogipoo@smgi.me>
Date: Mon, 27 Jan 2025 17:40:13 +0900
Subject: [PATCH 10/15] Add some basic interop tests

---
 .../Multiplayer/MultiplayerTest.cs            |  6 ++-
 .../Multiplayer/RoomInteropTest.cs            | 44 +++++++++++++++++++
 2 files changed, 48 insertions(+), 2 deletions(-)
 create mode 100644 osu.Server.Spectator.Tests/Multiplayer/RoomInteropTest.cs

diff --git a/osu.Server.Spectator.Tests/Multiplayer/MultiplayerTest.cs b/osu.Server.Spectator.Tests/Multiplayer/MultiplayerTest.cs
index 38d38def..1ccea078 100644
--- a/osu.Server.Spectator.Tests/Multiplayer/MultiplayerTest.cs
+++ b/osu.Server.Spectator.Tests/Multiplayer/MultiplayerTest.cs
@@ -38,6 +38,8 @@ public abstract class MultiplayerTest
         protected readonly Mock<IDatabaseFactory> DatabaseFactory;
         protected readonly Mock<IDatabaseAccess> Database;
 
+        protected readonly Mock<ILegacyIO> LegacyIO;
+
         /// <summary>
         /// A general non-gameplay receiver for the room with ID <see cref="ROOM_ID"/>.
         /// </summary>
@@ -131,7 +133,7 @@ protected MultiplayerTest()
             loggerFactoryMock.Setup(factory => factory.CreateLogger(It.IsAny<string>()))
                              .Returns(new Mock<ILogger>().Object);
 
-            var legacyIOMock = new Mock<ILegacyIO>();
+            LegacyIO = new Mock<ILegacyIO>();
 
             Hub = new TestMultiplayerHub(
                 loggerFactoryMock.Object,
@@ -140,7 +142,7 @@ protected MultiplayerTest()
                 DatabaseFactory.Object,
                 new ChatFilters(DatabaseFactory.Object),
                 hubContext.Object,
-                legacyIOMock.Object);
+                LegacyIO.Object);
             Hub.Groups = Groups.Object;
             Hub.Clients = Clients.Object;
 
diff --git a/osu.Server.Spectator.Tests/Multiplayer/RoomInteropTest.cs b/osu.Server.Spectator.Tests/Multiplayer/RoomInteropTest.cs
new file mode 100644
index 00000000..b29fafa5
--- /dev/null
+++ b/osu.Server.Spectator.Tests/Multiplayer/RoomInteropTest.cs
@@ -0,0 +1,44 @@
+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Moq;
+using osu.Game.Online.Multiplayer;
+using Xunit;
+
+namespace osu.Server.Spectator.Tests.Multiplayer
+{
+    public class RoomInteropTest : MultiplayerTest
+    {
+        [Fact]
+        public async Task CreateRoom()
+        {
+            LegacyIO.Setup(io => io.CreateRoomAsync(It.IsAny<int>(), It.IsAny<MultiplayerRoom>()))
+                    .ReturnsAsync(() => ROOM_ID);
+
+            await Hub.CreateRoom(new MultiplayerRoom(0));
+            LegacyIO.Verify(io => io.CreateRoomAsync(USER_ID, It.IsAny<MultiplayerRoom>()), Times.Once);
+            LegacyIO.Verify(io => io.JoinRoomAsync(ROOM_ID, USER_ID), Times.Once);
+
+            using (var usage = await Hub.GetRoom(ROOM_ID))
+            {
+                Assert.NotNull(usage.Item);
+                Assert.Equal(USER_ID, usage.Item.Users.Single().UserID);
+            }
+        }
+
+        [Fact]
+        public async Task LeaveRoom()
+        {
+            await Hub.JoinRoom(ROOM_ID);
+            LegacyIO.Verify(io => io.PartRoomAsync(ROOM_ID, USER_ID), Times.Never);
+
+            await Hub.LeaveRoom();
+            LegacyIO.Verify(io => io.PartRoomAsync(ROOM_ID, USER_ID), Times.Once);
+
+            await Assert.ThrowsAsync<KeyNotFoundException>(() => Hub.GetRoom(ROOM_ID));
+        }
+    }
+}

From 9b33f0515181cfbe96b18bb07605e00beca88ad1 Mon Sep 17 00:00:00 2001
From: Dan Balasescu <smoogipoo@smgi.me>
Date: Tue, 28 Jan 2025 19:42:57 +0900
Subject: [PATCH 11/15] Don't use switch expression

---
 osu.Server.Spectator/Services/LegacyIO.cs | 20 +++++++++++++++-----
 1 file changed, 15 insertions(+), 5 deletions(-)

diff --git a/osu.Server.Spectator/Services/LegacyIO.cs b/osu.Server.Spectator/Services/LegacyIO.cs
index cb166d49..108ca581 100644
--- a/osu.Server.Spectator/Services/LegacyIO.cs
+++ b/osu.Server.Spectator/Services/LegacyIO.cs
@@ -48,12 +48,22 @@ private async Task<string> runLegacyIO(HttpMethod method, string command, dynami
             long time = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
             string url = $"{interopDomain}/_lio/{command}{(command.Contains('?') ? "&" : "?")}timestamp={time}";
 
-            string? serialisedPostObject = postObject switch
+            string? serialisedPostObject;
+
+            switch (postObject)
             {
-                null => null,
-                string => postObject,
-                _ => JsonSerializer.Serialize(postObject)
-            };
+                case null:
+                    serialisedPostObject = null;
+                    break;
+
+                case string:
+                    serialisedPostObject = postObject;
+                    break;
+
+                default:
+                    serialisedPostObject = JsonSerializer.Serialize(postObject);
+                    break;
+            }
 
             logger.LogDebug("Performing LIO request to {method} {url} (params: {params})", method, url, serialisedPostObject);
 

From ca2e0c30273d78ec854f4c3703d5f8fc4ba4d9eb Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Mon, 10 Feb 2025 15:41:27 +0900
Subject: [PATCH 12/15] Rename IO methods and adjust xmldoc

---
 .../Multiplayer/RoomInteropTest.cs            |  6 +++---
 .../Hubs/Multiplayer/MultiplayerHub.cs        |  9 ++++++---
 osu.Server.Spectator/Services/ILegacyIO.cs    | 19 ++++++++++++++-----
 osu.Server.Spectator/Services/LegacyIO.cs     |  4 ++--
 4 files changed, 25 insertions(+), 13 deletions(-)

diff --git a/osu.Server.Spectator.Tests/Multiplayer/RoomInteropTest.cs b/osu.Server.Spectator.Tests/Multiplayer/RoomInteropTest.cs
index b29fafa5..d1809ac3 100644
--- a/osu.Server.Spectator.Tests/Multiplayer/RoomInteropTest.cs
+++ b/osu.Server.Spectator.Tests/Multiplayer/RoomInteropTest.cs
@@ -20,7 +20,7 @@ public async Task CreateRoom()
 
             await Hub.CreateRoom(new MultiplayerRoom(0));
             LegacyIO.Verify(io => io.CreateRoomAsync(USER_ID, It.IsAny<MultiplayerRoom>()), Times.Once);
-            LegacyIO.Verify(io => io.JoinRoomAsync(ROOM_ID, USER_ID), Times.Once);
+            LegacyIO.Verify(io => io.AddUserToRoomAsync(ROOM_ID, USER_ID), Times.Once);
 
             using (var usage = await Hub.GetRoom(ROOM_ID))
             {
@@ -33,10 +33,10 @@ public async Task CreateRoom()
         public async Task LeaveRoom()
         {
             await Hub.JoinRoom(ROOM_ID);
-            LegacyIO.Verify(io => io.PartRoomAsync(ROOM_ID, USER_ID), Times.Never);
+            LegacyIO.Verify(io => io.RemoveUserFromRoomAsync(ROOM_ID, USER_ID), Times.Never);
 
             await Hub.LeaveRoom();
-            LegacyIO.Verify(io => io.PartRoomAsync(ROOM_ID, USER_ID), Times.Once);
+            LegacyIO.Verify(io => io.RemoveUserFromRoomAsync(ROOM_ID, USER_ID), Times.Once);
 
             await Assert.ThrowsAsync<KeyNotFoundException>(() => Hub.GetRoom(ROOM_ID));
         }
diff --git a/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs b/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs
index 2d3f9f00..324b6b71 100644
--- a/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs
+++ b/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs
@@ -49,7 +49,10 @@ public MultiplayerHub(
         public async Task<MultiplayerRoom> CreateRoom(MultiplayerRoom room)
         {
             Log($"{Context.GetUserId()} creating room");
-            return await JoinRoomWithPassword(await legacyIO.CreateRoomAsync(Context.GetUserId(), room), room.Settings.Password);
+
+            long roomId = await legacyIO.CreateRoomAsync(Context.GetUserId(), room);
+
+            return await JoinRoomWithPassword(roomId, room.Settings.Password);
         }
 
         public Task<MultiplayerRoom> JoinRoom(long roomId) => JoinRoomWithPassword(roomId, string.Empty);
@@ -64,7 +67,7 @@ public async Task<MultiplayerRoom> JoinRoomWithPassword(long roomId, string pass
                     throw new InvalidStateException("Can't join a room when restricted.");
             }
 
-            await legacyIO.JoinRoomAsync(roomId, Context.GetUserId());
+            await legacyIO.AddUserToRoomAsync(roomId, Context.GetUserId());
 
             using (var userUsage = await GetOrCreateLocalUserState())
             {
@@ -914,7 +917,7 @@ private async Task<ItemUsage<ServerMultiplayerRoom>> getLocalUserRoom(Multiplaye
 
         private async Task leaveRoom(MultiplayerClientState state, bool wasKick)
         {
-            await legacyIO.PartRoomAsync(state.CurrentRoomID, state.UserId);
+            await legacyIO.RemoveUserFromRoomAsync(state.CurrentRoomID, state.UserId);
 
             using (var roomUsage = await getLocalUserRoom(state))
                 await leaveRoom(state, roomUsage, wasKick);
diff --git a/osu.Server.Spectator/Services/ILegacyIO.cs b/osu.Server.Spectator/Services/ILegacyIO.cs
index 7741583a..3543f695 100644
--- a/osu.Server.Spectator/Services/ILegacyIO.cs
+++ b/osu.Server.Spectator/Services/ILegacyIO.cs
@@ -9,25 +9,34 @@ namespace osu.Server.Spectator.Services
     public interface ILegacyIO
     {
         /// <summary>
-        /// Creates an osu!web Room.
+        /// Creates an osu!web room.
         /// </summary>
+        /// <remarks>
+        /// This does not join the creating user to the room. A subsequent call to <see cref="AddUserToRoomAsync"/> should be made if required.
+        /// </remarks>
         /// <param name="userId">The ID of the user that wants to create the room.</param>
         /// <param name="room">The room.</param>
         /// <returns>The room's ID.</returns>
         Task<long> CreateRoomAsync(int userId, MultiplayerRoom room);
 
         /// <summary>
-        /// Joins an osu!web Room.
+        /// Adds a user to an osu!web room.
         /// </summary>
+        /// <remarks>
+        /// This performs setup tasks like adding the user to the relevant chat channel.
+        /// </remarks>
         /// <param name="roomId">The ID of the room to join.</param>
         /// <param name="userId">The ID of the user wanting to join the room.</param>
-        Task JoinRoomAsync(long roomId, int userId);
+        Task AddUserToRoomAsync(long roomId, int userId);
 
         /// <summary>
-        /// Parts an osu!web Room.
+        /// Parts an osu!web room.
         /// </summary>
+        /// <remarks>
+        /// This performs setup tasks like removing the user from any relevant chat channels.
+        /// </remarks>
         /// <param name="roomId">The ID of the room to part.</param>
         /// <param name="userId">The ID of the user wanting to part the room.</param>
-        Task PartRoomAsync(long roomId, int userId);
+        Task RemoveUserFromRoomAsync(long roomId, int userId);
     }
 }
diff --git a/osu.Server.Spectator/Services/LegacyIO.cs b/osu.Server.Spectator/Services/LegacyIO.cs
index 108ca581..c2b32b1e 100644
--- a/osu.Server.Spectator/Services/LegacyIO.cs
+++ b/osu.Server.Spectator/Services/LegacyIO.cs
@@ -127,12 +127,12 @@ public async Task<long> CreateRoomAsync(int userId, MultiplayerRoom room)
             })));
         }
 
-        public async Task JoinRoomAsync(long roomId, int userId)
+        public async Task AddUserToRoomAsync(long roomId, int userId)
         {
             await runLegacyIO(HttpMethod.Put, $"multiplayer/rooms/{roomId}/users/{userId}");
         }
 
-        public async Task PartRoomAsync(long roomId, int userId)
+        public async Task RemoveUserFromRoomAsync(long roomId, int userId)
         {
             await runLegacyIO(HttpMethod.Delete, $"multiplayer/rooms/{roomId}/users/{userId}");
         }

From 7aa4aa7e9a76c8e7c317ff06b3c60e66499264f2 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Mon, 10 Feb 2025 15:50:41 +0900
Subject: [PATCH 13/15] Explicitly mention that user is host in creation
 request

---
 osu.Server.Spectator/Services/ILegacyIO.cs |  4 ++--
 osu.Server.Spectator/Services/LegacyIO.cs  | 12 ++++++------
 2 files changed, 8 insertions(+), 8 deletions(-)

diff --git a/osu.Server.Spectator/Services/ILegacyIO.cs b/osu.Server.Spectator/Services/ILegacyIO.cs
index 3543f695..61607f1b 100644
--- a/osu.Server.Spectator/Services/ILegacyIO.cs
+++ b/osu.Server.Spectator/Services/ILegacyIO.cs
@@ -14,10 +14,10 @@ public interface ILegacyIO
         /// <remarks>
         /// This does not join the creating user to the room. A subsequent call to <see cref="AddUserToRoomAsync"/> should be made if required.
         /// </remarks>
-        /// <param name="userId">The ID of the user that wants to create the room.</param>
+        /// <param name="hostUserId">The ID of the user that wants to create the room.</param>
         /// <param name="room">The room.</param>
         /// <returns>The room's ID.</returns>
-        Task<long> CreateRoomAsync(int userId, MultiplayerRoom room);
+        Task<long> CreateRoomAsync(int hostUserId, MultiplayerRoom room);
 
         /// <summary>
         /// Adds a user to an osu!web room.
diff --git a/osu.Server.Spectator/Services/LegacyIO.cs b/osu.Server.Spectator/Services/LegacyIO.cs
index c2b32b1e..7b01563b 100644
--- a/osu.Server.Spectator/Services/LegacyIO.cs
+++ b/osu.Server.Spectator/Services/LegacyIO.cs
@@ -119,11 +119,11 @@ private static string hmacEncode(string input, byte[] key)
         // Methods below purposefully async-await on `runLegacyIO()` calls rather than directly returning the underlying calls.
         // This is done for better readability of exception stacks. Directly returning the tasks elides the name of the proxying method.
 
-        public async Task<long> CreateRoomAsync(int userId, MultiplayerRoom room)
+        public async Task<long> CreateRoomAsync(int hostUserId, MultiplayerRoom room)
         {
-            return long.Parse(await runLegacyIO(HttpMethod.Post, "multiplayer/rooms", Newtonsoft.Json.JsonConvert.SerializeObject(new CreateRoomRequest(room)
+            return long.Parse(await runLegacyIO(HttpMethod.Post, "multiplayer/rooms", Newtonsoft.Json.JsonConvert.SerializeObject(new RoomWithHostId(room)
             {
-                UserId = userId
+                HostUserId = hostUserId
             })));
         }
 
@@ -137,15 +137,15 @@ public async Task RemoveUserFromRoomAsync(long roomId, int userId)
             await runLegacyIO(HttpMethod.Delete, $"multiplayer/rooms/{roomId}/users/{userId}");
         }
 
-        private class CreateRoomRequest : Room
+        private class RoomWithHostId : Room
         {
             [Newtonsoft.Json.JsonProperty("user_id")]
-            public required int UserId { get; init; }
+            public required int HostUserId { get; init; }
 
             /// <summary>
             /// Creates a <see cref="Room"/> from a <see cref="MultiplayerRoom"/>.
             /// </summary>
-            public CreateRoomRequest(MultiplayerRoom room)
+            public RoomWithHostId(MultiplayerRoom room)
             {
                 RoomID = room.RoomID;
                 Host = room.Host?.User;

From ebf3a4b1940849f063c377a6a672a5a4a43215fe Mon Sep 17 00:00:00 2001
From: Dan Balasescu <smoogipoo@smgi.me>
Date: Tue, 11 Feb 2025 20:10:28 +0900
Subject: [PATCH 14/15] Remove populating of `CurrentPlaylistItem` + document

---
 osu.Server.Spectator/Services/LegacyIO.cs | 1 -
 1 file changed, 1 deletion(-)

diff --git a/osu.Server.Spectator/Services/LegacyIO.cs b/osu.Server.Spectator/Services/LegacyIO.cs
index 7b01563b..c9b42ab8 100644
--- a/osu.Server.Spectator/Services/LegacyIO.cs
+++ b/osu.Server.Spectator/Services/LegacyIO.cs
@@ -156,7 +156,6 @@ public RoomWithHostId(MultiplayerRoom room)
                 AutoStartDuration = room.Settings.AutoStartDuration;
                 AutoSkip = room.Settings.AutoSkip;
                 Playlist = room.Playlist.Select(item => new PlaylistItem(item)).ToArray();
-                CurrentPlaylistItem = Playlist.FirstOrDefault(item => item.ID == room.Settings.PlaylistItemId);
             }
         }
 

From 0a5343377a508d685c5df3fa1a91ea0a2c219727 Mon Sep 17 00:00:00 2001
From: Dan Balasescu <smoogipoo@smgi.me>
Date: Tue, 11 Feb 2025 20:16:36 +0900
Subject: [PATCH 15/15] Add some documentation

---
 osu.Server.Spectator/Services/LegacyIO.cs | 6 ++++++
 1 file changed, 6 insertions(+)

diff --git a/osu.Server.Spectator/Services/LegacyIO.cs b/osu.Server.Spectator/Services/LegacyIO.cs
index c9b42ab8..dba74ec1 100644
--- a/osu.Server.Spectator/Services/LegacyIO.cs
+++ b/osu.Server.Spectator/Services/LegacyIO.cs
@@ -137,8 +137,14 @@ public async Task RemoveUserFromRoomAsync(long roomId, int userId)
             await runLegacyIO(HttpMethod.Delete, $"multiplayer/rooms/{roomId}/users/{userId}");
         }
 
+        /// <summary>
+        /// A special <see cref="Room"/> that can be serialised with Newtonsoft.Json to create rooms hosted by a given <see cref="HostUserId">user</see>.
+        /// </summary>
         private class RoomWithHostId : Room
         {
+            /// <summary>
+            /// The ID of the user to host the room.
+            /// </summary>
             [Newtonsoft.Json.JsonProperty("user_id")]
             public required int HostUserId { get; init; }