diff --git a/SVSim.EmulatedEntrypoint/Controllers/ProfileController.cs b/SVSim.EmulatedEntrypoint/Controllers/ProfileController.cs
new file mode 100644
index 0000000..843a3de
--- /dev/null
+++ b/SVSim.EmulatedEntrypoint/Controllers/ProfileController.cs
@@ -0,0 +1,51 @@
+using Microsoft.AspNetCore.Mvc;
+using SVSim.Database.Repositories.Viewer;
+using SVSim.EmulatedEntrypoint.Constants;
+using SVSim.EmulatedEntrypoint.Models.Dtos;
+using SVSim.EmulatedEntrypoint.Models.Dtos.Profile;
+
+namespace SVSim.EmulatedEntrypoint.Controllers;
+
+///
+/// /profile/* — viewer-scoped profile read endpoint. Surfaces total rank-match wins
+/// and the per-class roster (level, exp, leader-skin selection).
+///
+[Route("profile")]
+public sealed class ProfileController : SVSimController
+{
+ private readonly IViewerRepository _viewerRepository;
+
+ public ProfileController(IViewerRepository viewerRepository) =>
+ _viewerRepository = viewerRepository;
+
+ [HttpPost("index")]
+ public async Task> Index(
+ [FromBody] ProfileIndexRequest _,
+ CancellationToken ct)
+ {
+ var shortUdidClaim = User.Claims.FirstOrDefault(c => c.Type == ShadowverseClaimTypes.ShortUdidClaim)?.Value;
+ if (shortUdidClaim is null || !long.TryParse(shortUdidClaim, out long shortUdid))
+ return Unauthorized();
+
+ var viewer = await _viewerRepository.GetViewerByShortUdid(shortUdid);
+ if (viewer is null) return NotFound();
+
+ var skinsByClass = viewer.LeaderSkins
+ .Where(s => s.ClassId.HasValue)
+ .GroupBy(s => s.ClassId!.Value)
+ .ToDictionary(g => g.Key, g => (IReadOnlyCollection)g.Select(s => s.Id).ToList());
+
+ var classes = viewer.Classes
+ .Select(vc => new UserClass(
+ vc,
+ skinsByClass.GetValueOrDefault(vc.Class.Id, Array.Empty())))
+ .ToList();
+
+ return new ProfileIndexResponse
+ {
+ // TODO: when rank-match results are tracked, compute from viewer's rank history.
+ UserRankMatchTotalWin = 0,
+ UserClassList = classes,
+ };
+ }
+}
diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Profile/ProfileIndexRequest.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Profile/ProfileIndexRequest.cs
new file mode 100644
index 0000000..0ee1ce8
--- /dev/null
+++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Profile/ProfileIndexRequest.cs
@@ -0,0 +1,12 @@
+using MessagePack;
+
+namespace SVSim.EmulatedEntrypoint.Models.Dtos.Profile;
+
+///
+/// Empty request body. The endpoint takes no parameters (client task uses BaseParam directly);
+/// this DTO exists so model binding resolves the envelope correctly.
+///
+[MessagePackObject(true)]
+public sealed class ProfileIndexRequest
+{
+}
diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Profile/ProfileIndexResponse.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Profile/ProfileIndexResponse.cs
new file mode 100644
index 0000000..9d94d2f
--- /dev/null
+++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Profile/ProfileIndexResponse.cs
@@ -0,0 +1,17 @@
+using System.Text.Json.Serialization;
+using MessagePack;
+using SVSim.EmulatedEntrypoint.Models.Dtos;
+
+namespace SVSim.EmulatedEntrypoint.Models.Dtos.Profile;
+
+[MessagePackObject]
+public sealed class ProfileIndexResponse
+{
+ [JsonPropertyName("user_rank_match_total_win")]
+ [Key("user_rank_match_total_win")]
+ public int UserRankMatchTotalWin { get; set; }
+
+ [JsonPropertyName("user_class_list")]
+ [Key("user_class_list")]
+ public List UserClassList { get; set; } = new();
+}
diff --git a/SVSim.UnitTests/Controllers/ProfileControllerTests.cs b/SVSim.UnitTests/Controllers/ProfileControllerTests.cs
new file mode 100644
index 0000000..a6ac642
--- /dev/null
+++ b/SVSim.UnitTests/Controllers/ProfileControllerTests.cs
@@ -0,0 +1,163 @@
+using System.Net;
+using System.Net.Http.Json;
+using System.Text;
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using SVSim.Database;
+using SVSim.Database.Models;
+using SVSim.UnitTests.Infrastructure;
+
+namespace SVSim.UnitTests.Controllers;
+
+public class ProfileControllerTests
+{
+ private static StringContent JsonBody(string json) => new(json, Encoding.UTF8, "application/json");
+
+ [Test]
+ public async Task Index_returns_zero_total_wins_for_fresh_viewer()
+ {
+ using var factory = new SVSimTestFactory();
+ long viewerId = await factory.SeedViewerAsync();
+ using var client = factory.CreateAuthenticatedClient(viewerId);
+
+ var response = await client.PostAsync("/profile/index", JsonBody("{}"));
+ var raw = await response.Content.ReadAsStringAsync();
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), raw);
+
+ using var doc = JsonDocument.Parse(raw);
+ Assert.That(doc.RootElement.GetProperty("user_rank_match_total_win").GetInt32(), Is.EqualTo(0));
+ }
+
+ [Test]
+ public async Task Index_returns_all_viewer_classes()
+ {
+ using var factory = new SVSimTestFactory();
+ long viewerId = await factory.SeedViewerAsync();
+ using var client = factory.CreateAuthenticatedClient(viewerId);
+
+ var response = await client.PostAsync("/profile/index", JsonBody("{}"));
+ var raw = await response.Content.ReadAsStringAsync();
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), raw);
+
+ using var doc = JsonDocument.Parse(raw);
+ var classes = doc.RootElement.GetProperty("user_class_list");
+ Assert.That(classes.GetArrayLength(), Is.GreaterThanOrEqualTo(8),
+ "fresh viewer should have at least 8 main-class entries");
+ var classIds = Enumerable.Range(0, classes.GetArrayLength())
+ .Select(i => classes[i].GetProperty("class_id").GetInt32())
+ .ToList();
+ Assert.That(classIds, Does.Contain(1));
+ Assert.That(classIds, Does.Contain(8));
+ }
+
+ [Test]
+ public async Task Index_class_entry_carries_level_exp_default_skin()
+ {
+ using var factory = new SVSimTestFactory();
+ long viewerId = await factory.SeedViewerAsync();
+
+ // Seed level + exp on class 1.
+ using (var seedScope = factory.Services.CreateScope())
+ {
+ var ctx = seedScope.ServiceProvider.GetRequiredService();
+ var viewer = await ctx.Viewers.Include(v => v.Classes).ThenInclude(c => c.Class).FirstAsync(v => v.Id == viewerId);
+ var cls1 = viewer.Classes.First(c => c.Class.Id == 1);
+ cls1.Level = 5;
+ cls1.Exp = 600;
+ await ctx.SaveChangesAsync();
+ }
+
+ using var client = factory.CreateAuthenticatedClient(viewerId);
+ var response = await client.PostAsync("/profile/index", JsonBody("{}"));
+ var raw = await response.Content.ReadAsStringAsync();
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), raw);
+
+ using var doc = JsonDocument.Parse(raw);
+ var entry = doc.RootElement.GetProperty("user_class_list")
+ .EnumerateArray()
+ .First(e => e.GetProperty("class_id").GetInt32() == 1);
+ Assert.That(entry.GetProperty("level").GetInt32(), Is.EqualTo(5));
+ Assert.That(entry.GetProperty("exp").GetInt32(), Is.EqualTo(600));
+ Assert.That(entry.GetProperty("default_leader_skin_id").GetInt32(), Is.GreaterThan(0));
+ }
+
+ [Test]
+ public async Task Index_leader_skin_id_list_populated_from_owned_skins()
+ {
+ using var factory = new SVSimTestFactory();
+ long viewerId = await factory.SeedViewerAsync();
+
+ // Grant the viewer two additional leader skins for class 1.
+ const int extraSkinA = 10001;
+ const int extraSkinB = 10002;
+ using (var seedScope = factory.Services.CreateScope())
+ {
+ var ctx = seedScope.ServiceProvider.GetRequiredService();
+ ctx.LeaderSkins.Add(new LeaderSkinEntry { Id = extraSkinA, Name = "extraA", ClassId = 1 });
+ ctx.LeaderSkins.Add(new LeaderSkinEntry { Id = extraSkinB, Name = "extraB", ClassId = 1 });
+ await ctx.SaveChangesAsync();
+
+ var viewer = await ctx.Viewers.Include(v => v.LeaderSkins).FirstAsync(v => v.Id == viewerId);
+ viewer.LeaderSkins.Add(await ctx.LeaderSkins.FindAsync(extraSkinA) ?? throw new InvalidOperationException());
+ viewer.LeaderSkins.Add(await ctx.LeaderSkins.FindAsync(extraSkinB) ?? throw new InvalidOperationException());
+ await ctx.SaveChangesAsync();
+ }
+
+ using var client = factory.CreateAuthenticatedClient(viewerId);
+ var response = await client.PostAsync("/profile/index", JsonBody("{}"));
+ var raw = await response.Content.ReadAsStringAsync();
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), raw);
+
+ using var doc = JsonDocument.Parse(raw);
+ var entry = doc.RootElement.GetProperty("user_class_list")
+ .EnumerateArray()
+ .First(e => e.GetProperty("class_id").GetInt32() == 1);
+ var list = entry.GetProperty("leader_skin_id_list");
+ var ids = Enumerable.Range(0, list.GetArrayLength()).Select(i => list[i].GetInt32()).ToList();
+ Assert.That(ids, Does.Contain(extraSkinA));
+ Assert.That(ids, Does.Contain(extraSkinB));
+ Assert.That(ids.Count, Is.GreaterThanOrEqualTo(2));
+ }
+
+ [Test]
+ public async Task Index_is_random_leader_skin_reflects_persisted_value()
+ {
+ using var factory = new SVSimTestFactory();
+ long viewerId = await factory.SeedViewerAsync();
+
+ using (var seedScope = factory.Services.CreateScope())
+ {
+ var ctx = seedScope.ServiceProvider.GetRequiredService();
+ var viewer = await ctx.Viewers.Include(v => v.Classes).ThenInclude(c => c.Class).FirstAsync(v => v.Id == viewerId);
+ viewer.Classes.First(c => c.Class.Id == 1).IsRandomLeaderSkin = true;
+ await ctx.SaveChangesAsync();
+ }
+
+ using var client = factory.CreateAuthenticatedClient(viewerId);
+ var response = await client.PostAsync("/profile/index", JsonBody("{}"));
+ var raw = await response.Content.ReadAsStringAsync();
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), raw);
+
+ using var doc = JsonDocument.Parse(raw);
+ var class1 = doc.RootElement.GetProperty("user_class_list")
+ .EnumerateArray()
+ .First(e => e.GetProperty("class_id").GetInt32() == 1);
+ Assert.That(class1.GetProperty("is_random_leader_skin").GetInt32(), Is.EqualTo(1));
+
+ var class2 = doc.RootElement.GetProperty("user_class_list")
+ .EnumerateArray()
+ .First(e => e.GetProperty("class_id").GetInt32() == 2);
+ Assert.That(class2.GetProperty("is_random_leader_skin").GetInt32(), Is.EqualTo(0));
+ }
+
+ [Test]
+ public async Task Index_without_auth_returns_401()
+ {
+ using var factory = new SVSimTestFactory();
+ var client = factory.CreateClient(); // no X-Test-Viewer-Id header
+
+ var response = await client.PostAsync("/profile/index", JsonBody("{}"));
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
+ }
+}