diff --git a/osu.Server.Spectator.Tests/SpectatorHubTest.cs b/osu.Server.Spectator.Tests/SpectatorHubTest.cs
index dc0c5f45..1893f184 100644
--- a/osu.Server.Spectator.Tests/SpectatorHubTest.cs
+++ b/osu.Server.Spectator.Tests/SpectatorHubTest.cs
@@ -3,14 +3,17 @@
 
 using System;
 using System.Collections.Generic;
+using System.Linq;
 using System.Threading.Tasks;
 using Microsoft.AspNetCore.SignalR;
 using Microsoft.Extensions.Logging;
 using Moq;
 using osu.Game.Beatmaps;
+using osu.Game.Online.API;
 using osu.Game.Online.API.Requests.Responses;
 using osu.Game.Online.Spectator;
 using osu.Game.Replays.Legacy;
+using osu.Game.Rulesets.Osu.Mods;
 using osu.Game.Rulesets.Scoring;
 using osu.Game.Scoring;
 using osu.Server.Spectator.Database;
@@ -208,6 +211,111 @@ await hub.EndPlaySession(new SpectatorState
             mockReceiver.Verify(clients => clients.UserFinishedPlaying(streamer_id, It.Is<SpectatorState>(m => m.State == SpectatedUserState.Quit)), Times.Once());
         }
 
+        [Fact]
+        public async Task ModChangesDuringPlayAreHandled()
+        {
+            scoreUploader.SaveReplays = true;
+
+            Mock<IHubCallerClients<ISpectatorClient>> mockClients = new Mock<IHubCallerClients<ISpectatorClient>>();
+            Mock<ISpectatorClient> mockReceiver = new Mock<ISpectatorClient>();
+            mockClients.Setup(clients => clients.All).Returns(mockReceiver.Object);
+            mockClients.Setup(clients => clients.Group(SpectatorHub.GetGroupId(streamer_id))).Returns(mockReceiver.Object);
+
+            Mock<HubCallerContext> mockContext = new Mock<HubCallerContext>();
+
+            mockContext.Setup(context => context.UserIdentifier).Returns(streamer_id.ToString());
+            hub.Context = mockContext.Object;
+            hub.Clients = mockClients.Object;
+
+            mockDatabase.Setup(db => db.GetScoreFromTokenAsync(1234)).Returns(Task.FromResult<SoloScore?>(new SoloScore
+            {
+                id = 456,
+                passed = true
+            }));
+
+            await hub.BeginPlaySession(1234, new SpectatorState
+            {
+                BeatmapID = beatmap_id,
+                RulesetID = 0,
+                State = SpectatedUserState.Playing,
+            });
+
+            await hub.SendFrameData(new FrameDataBundle(
+                new FrameHeader(new ScoreInfo
+                {
+                    Mods = [new OsuModTouchDevice()],
+                    Statistics = new Dictionary<HitResult, int> { [HitResult.Great] = 1 }
+                }, new ScoreProcessorStatistics()),
+                new[] { new LegacyReplayFrame(1234, 0, 0, ReplayButtonState.None) }));
+
+            await hub.EndPlaySession(new SpectatorState
+            {
+                BeatmapID = beatmap_id,
+                RulesetID = 0,
+                State = SpectatedUserState.Quit,
+            });
+
+            await uploadsCompleteAsync();
+
+            mockScoreStorage.Verify(s => s.WriteAsync(It.Is<Score>(s => s.ScoreInfo.Mods.Any(m => m is OsuModTouchDevice))), Times.Once);
+            mockReceiver.Verify(clients => clients.UserFinishedPlaying(streamer_id, It.Is<SpectatorState>(m => m.State == SpectatedUserState.Quit)), Times.Once());
+        }
+
+        [Fact]
+        public async Task FrameBundlesFromOldClientsWithoutModsHandledCorrectly()
+        {
+            scoreUploader.SaveReplays = true;
+
+            Mock<IHubCallerClients<ISpectatorClient>> mockClients = new Mock<IHubCallerClients<ISpectatorClient>>();
+            Mock<ISpectatorClient> mockReceiver = new Mock<ISpectatorClient>();
+            mockClients.Setup(clients => clients.All).Returns(mockReceiver.Object);
+            mockClients.Setup(clients => clients.Group(SpectatorHub.GetGroupId(streamer_id))).Returns(mockReceiver.Object);
+
+            Mock<HubCallerContext> mockContext = new Mock<HubCallerContext>();
+
+            mockContext.Setup(context => context.UserIdentifier).Returns(streamer_id.ToString());
+            hub.Context = mockContext.Object;
+            hub.Clients = mockClients.Object;
+
+            mockDatabase.Setup(db => db.GetScoreFromTokenAsync(1234)).Returns(Task.FromResult<SoloScore?>(new SoloScore
+            {
+                id = 456,
+                passed = true
+            }));
+
+            await hub.BeginPlaySession(1234, new SpectatorState
+            {
+                BeatmapID = beatmap_id,
+                RulesetID = 0,
+                State = SpectatedUserState.Playing,
+                Mods = [new APIMod(new OsuModDoubleTime())]
+            });
+
+            var frameHeader = new FrameHeader(new ScoreInfo
+            {
+                Statistics = new Dictionary<HitResult, int> { [HitResult.Great] = 1 }
+            }, new ScoreProcessorStatistics())
+            {
+                Mods = null, // simulate older client that did not send this property over wire
+            };
+
+            await hub.SendFrameData(new FrameDataBundle(
+                frameHeader,
+                new[] { new LegacyReplayFrame(1234, 0, 0, ReplayButtonState.None) }));
+
+            await hub.EndPlaySession(new SpectatorState
+            {
+                BeatmapID = beatmap_id,
+                RulesetID = 0,
+                State = SpectatedUserState.Quit,
+            });
+
+            await uploadsCompleteAsync();
+
+            mockScoreStorage.Verify(s => s.WriteAsync(It.Is<Score>(s => s.ScoreInfo.Mods.Any(m => m is OsuModDoubleTime))), Times.Once);
+            mockReceiver.Verify(clients => clients.UserFinishedPlaying(streamer_id, It.Is<SpectatorState>(m => m.State == SpectatedUserState.Quit)), Times.Once());
+        }
+
         [Theory]
         [InlineData(false)]
         [InlineData(true)]
diff --git a/osu.Server.Spectator/Hubs/Spectator/SpectatorHub.cs b/osu.Server.Spectator/Hubs/Spectator/SpectatorHub.cs
index 7b076418..43c42b56 100644
--- a/osu.Server.Spectator/Hubs/Spectator/SpectatorHub.cs
+++ b/osu.Server.Spectator/Hubs/Spectator/SpectatorHub.cs
@@ -124,6 +124,11 @@ public async Task SendFrameData(FrameDataBundle data)
                 score.ScoreInfo.Combo = data.Header.Combo;
                 score.ScoreInfo.TotalScore = data.Header.TotalScore;
 
+                // null here means the frame bundle is from an old client that can't send mod data
+                // can be removed (along with making property non-nullable on `FrameDataBundle`) 20250407
+                if (data.Header.Mods != null)
+                    score.ScoreInfo.APIMods = data.Header.Mods;
+
                 score.Replay.Frames.AddRange(data.Frames);
 
                 await Clients.Group(GetGroupId(Context.GetUserId())).UserSentFrames(Context.GetUserId(), data);