diff --git a/SVSim.EmulatedEntrypoint/Controllers/ReplayController.cs b/SVSim.EmulatedEntrypoint/Controllers/ReplayController.cs
new file mode 100644
index 0000000..db4faa6
--- /dev/null
+++ b/SVSim.EmulatedEntrypoint/Controllers/ReplayController.cs
@@ -0,0 +1,68 @@
+using System.Globalization;
+using Microsoft.AspNetCore.Mvc;
+using SVSim.Database.Services.Replay;
+using SVSim.EmulatedEntrypoint.Models.Dtos.Replay;
+
+namespace SVSim.EmulatedEntrypoint.Controllers;
+
+///
+/// Replay menu — recent-battles list + per-battle detail stub.
+/// /replay/info returns up to 50 rows newest-first from ViewerBattleHistories.
+/// /replay/detail returns 400 (result_code=99) — local cache is the canonical
+/// playback source; this endpoint is only hit on cache miss, and we don't store
+/// replay payloads. The client (ReplayDialogContent.GoReplay) aborts the scene
+/// transition cleanly on non-success.
+///
+[Route("replay")]
+public sealed class ReplayController : SVSimController
+{
+ private const string TimeFormat = "yyyy-MM-dd HH:mm:ss";
+
+ private readonly IReplayHistoryReader _reader;
+
+ public ReplayController(IReplayHistoryReader reader) => _reader = reader;
+
+ [HttpPost("info")]
+ public async Task Info(CancellationToken ct)
+ {
+ if (!TryGetViewerId(out var vid)) return Unauthorized();
+
+ var rows = await _reader.GetRecentAsync(vid, take: 50, ct);
+ var resp = new ReplayInfoResponseDto
+ {
+ ReplayList = rows.Select(MapToWire).ToList(),
+ };
+ return Ok(resp);
+ }
+
+ [HttpPost("detail")]
+ public IActionResult Detail([FromBody] ReplayDetailRequestDto req)
+ {
+ if (!TryGetViewerId(out _)) return Unauthorized();
+ return BadRequest(new { result_code = 99 });
+ }
+
+ private static ReplayInfoItemDto MapToWire(ReplayHistoryEntry e) => new()
+ {
+ BattleType = e.BattleType.ToString(CultureInfo.InvariantCulture),
+ TwoPickType = e.TwoPickType.ToString(CultureInfo.InvariantCulture),
+ DeckFormat = e.DeckFormat.ToString(CultureInfo.InvariantCulture),
+ BattleId = e.BattleId.ToString(CultureInfo.InvariantCulture),
+ IsLimitTurn = e.IsLimitTurn.ToString(CultureInfo.InvariantCulture),
+ OpponentName = e.OpponentName,
+ ClassId = e.SelfClassId.ToString(CultureInfo.InvariantCulture),
+ OpponentClassId = e.OpponentClassId.ToString(CultureInfo.InvariantCulture),
+ SubClassId = e.SelfSubClassId.ToString(CultureInfo.InvariantCulture),
+ OpponentSubClassId = e.OpponentSubClassId.ToString(CultureInfo.InvariantCulture),
+ RotationId = e.SelfRotationId,
+ OpponentRotationId = e.OpponentRotationId,
+ OpponentCountryCode = e.OpponentCountryCode,
+ CharaId = e.SelfCharaId.ToString(CultureInfo.InvariantCulture),
+ OpponentCharaId = e.OpponentCharaId.ToString(CultureInfo.InvariantCulture),
+ OpponentEmblemId = e.OpponentEmblemId.ToString(CultureInfo.InvariantCulture),
+ OpponentDegreeId = e.OpponentDegreeId.ToString(CultureInfo.InvariantCulture),
+ IsWin = e.IsWin ? "1" : "0",
+ BattleStartTime = e.BattleStartTime.ToString(TimeFormat, CultureInfo.InvariantCulture),
+ CreateTime = e.CreateTime.ToString(TimeFormat, CultureInfo.InvariantCulture),
+ };
+}
diff --git a/SVSim.UnitTests/Controllers/ReplayControllerTests.cs b/SVSim.UnitTests/Controllers/ReplayControllerTests.cs
new file mode 100644
index 0000000..5ede95e
--- /dev/null
+++ b/SVSim.UnitTests/Controllers/ReplayControllerTests.cs
@@ -0,0 +1,104 @@
+using System.Net;
+using System.Net.Http.Json;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using SVSim.Database;
+using SVSim.Database.Models;
+using SVSim.EmulatedEntrypoint.Models.Dtos.Replay;
+using SVSim.UnitTests.Infrastructure;
+
+namespace SVSim.UnitTests.Controllers;
+
+public class ReplayControllerTests
+{
+ [Test]
+ public async Task ReplayInfo_returns_empty_list_for_fresh_viewer()
+ {
+ using var factory = new SVSimTestFactory();
+ long viewerId = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_001UL);
+ var client = factory.CreateAuthenticatedClient(viewerId);
+
+ var resp = await client.PostAsJsonAsync("/replay/info", new { });
+ Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK));
+
+ var body = await resp.Content.ReadFromJsonAsync();
+ Assert.That(body!.ReplayList, Is.Empty);
+ }
+
+ [Test]
+ public async Task ReplayInfo_returns_recent_rows_newest_first()
+ {
+ using var factory = new SVSimTestFactory();
+ long viewerId = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_001UL);
+
+ using (var seedScope = factory.Services.CreateScope())
+ {
+ var db = seedScope.ServiceProvider.GetRequiredService();
+ db.ViewerBattleHistories.AddRange(
+ NewRow(viewerId, battleId: 1, createTime: new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc)),
+ NewRow(viewerId, battleId: 2, createTime: new DateTime(2026, 6, 2, 0, 0, 0, DateTimeKind.Utc)),
+ NewRow(viewerId, battleId: 3, createTime: new DateTime(2026, 6, 3, 0, 0, 0, DateTimeKind.Utc)));
+ await db.SaveChangesAsync();
+ }
+
+ var client = factory.CreateAuthenticatedClient(viewerId);
+ var body = await client.PostAsJsonAsync("/replay/info", new { })
+ .ContinueWith(t => t.Result.Content.ReadFromJsonAsync())
+ .Unwrap();
+
+ Assert.That(body!.ReplayList.Select(r => r.BattleId).ToList(),
+ Is.EqualTo(new[] { "3", "2", "1" }));
+ }
+
+ [Test]
+ public async Task ReplayInfo_does_not_leak_other_viewers_rows()
+ {
+ using var factory = new SVSimTestFactory();
+ long viewerA = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_001UL);
+ long viewerB = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_002UL);
+
+ using (var scope = factory.Services.CreateScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ db.ViewerBattleHistories.Add(NewRow(viewerA, battleId: 100, createTime: DateTime.UtcNow));
+ db.ViewerBattleHistories.Add(NewRow(viewerB, battleId: 200, createTime: DateTime.UtcNow));
+ await db.SaveChangesAsync();
+ }
+
+ var client = factory.CreateAuthenticatedClient(viewerA);
+ var body = await client.PostAsJsonAsync("/replay/info", new { })
+ .ContinueWith(t => t.Result.Content.ReadFromJsonAsync())
+ .Unwrap();
+
+ Assert.That(body!.ReplayList, Has.Count.EqualTo(1));
+ Assert.That(body.ReplayList[0].BattleId, Is.EqualTo("100"));
+ }
+
+ [Test]
+ public async Task ReplayDetail_returns_non_success_status()
+ {
+ using var factory = new SVSimTestFactory();
+ long viewerId = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_001UL);
+ var client = factory.CreateAuthenticatedClient(viewerId);
+
+ var resp = await client.PostAsJsonAsync("/replay/detail", new
+ {
+ viewer_id = viewerId,
+ battle_id = 234_471_983_876L,
+ });
+
+ Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
+ }
+
+ private static ViewerBattleHistory NewRow(long viewerId, long battleId, DateTime createTime) => new()
+ {
+ ViewerId = viewerId,
+ BattleId = battleId,
+ SelfRotationId = "0",
+ OpponentName = "",
+ OpponentCountryCode = "",
+ OpponentRotationId = "0",
+ BattleStartTime = createTime.AddMinutes(-3),
+ CreateTime = createTime,
+ };
+}