diff --git a/src/Configuration/ServerOptions.cs b/src/Configuration/ServerOptions.cs
index 537e11a..8552ee1 100644
--- a/src/Configuration/ServerOptions.cs
+++ b/src/Configuration/ServerOptions.cs
@@ -12,6 +12,13 @@ public sealed class PathsOptions
{
public string AssetDatabase { get; init; } = string.Empty;
public string MasterDatabase { get; init; } = string.Empty;
+
+ ///
+ /// Replacement URL written into list.bin in-place when serving asset lists.
+ /// Must be exactly 43 ASCII bytes to preserve protobuf field lengths.
+ /// Leave empty to serve list.bin unmodified.
+ ///
+ public string ResourcesBaseUrl { get; init; } = string.Empty;
}
public sealed class DataOptions
diff --git a/src/Data/DarkUserMemoryDatabase.cs b/src/Data/DarkUserMemoryDatabase.cs
index 707f692..998efe4 100644
--- a/src/Data/DarkUserMemoryDatabase.cs
+++ b/src/Data/DarkUserMemoryDatabase.cs
@@ -218,4 +218,7 @@ public class DarkUserMemoryDatabase
public List EntityIUserWebviewPanelMission { get; set; } = [];
+ // Server-exclusive data (EntityS* prefix): never sent to client
+ public List EntitySUserDevice { get; set; } = [];
+
}
diff --git a/src/Data/UserDataDiffBuilder.cs b/src/Data/UserDataDiffBuilder.cs
new file mode 100644
index 0000000..5fc0fd1
--- /dev/null
+++ b/src/Data/UserDataDiffBuilder.cs
@@ -0,0 +1,107 @@
+using MariesWonderland.Proto.Data;
+using System.Text.Json;
+
+namespace MariesWonderland.Data;
+
+public static class UserDataDiffBuilder
+{
+ private static readonly JsonSerializerOptions CamelCaseOptions = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase
+ };
+
+ // Maps "IUser" -> func that serializes db.EntityIUser to JSON
+ private static readonly IReadOnlyDictionary> Serializers;
+
+ static UserDataDiffBuilder()
+ {
+ var serializers = new Dictionary>();
+
+ foreach (var prop in typeof(DarkUserMemoryDatabase).GetProperties())
+ {
+ // Only client-visible user data tables
+ if (!prop.Name.StartsWith("EntityI")) continue;
+ if (!prop.PropertyType.IsGenericType) continue;
+ if (prop.PropertyType.GetGenericTypeDefinition() != typeof(List<>)) continue;
+
+ var tableName = prop.Name["Entity".Length..]; // "EntityIUser" -> "IUser"
+ var capturedProp = prop; // capture for lambda
+ serializers[tableName] = db =>
+ {
+ var list = capturedProp.GetValue(db);
+ return JsonSerializer.Serialize(list, CamelCaseOptions);
+ };
+ }
+
+ Serializers = serializers;
+ }
+
+ /// All client-visible user table names (IUser* series, excludes EntityS* and EntityM*).
+ public static IEnumerable TableNames => Serializers.Keys;
+
+ ///
+ /// Captures the current state of all non-empty client-visible user tables as serialized JSON.
+ /// Use this before making changes; pass the result to Delta() after changes.
+ ///
+ public static Dictionary Snapshot(DarkUserMemoryDatabase db)
+ {
+ var snapshot = new Dictionary();
+ foreach (var (table, serialize) in Serializers)
+ {
+ var json = serialize(db);
+ if (json != "[]")
+ snapshot[table] = json;
+ }
+ return snapshot;
+ }
+
+ ///
+ /// Builds a full diff of all client-visible user tables.
+ /// Non-empty tables contain their data; empty tables are explicitly sent as "[]".
+ /// Use this for Auth and RegisterUser to perform a complete client sync.
+ ///
+ public static Dictionary FullDiff(DarkUserMemoryDatabase db)
+ {
+ var diff = new Dictionary();
+ foreach (var (table, serialize) in Serializers)
+ {
+ diff[table] = new DiffData
+ {
+ UpdateRecordsJson = serialize(db),
+ DeleteKeysJson = "[]"
+ };
+ }
+ return diff;
+ }
+
+ ///
+ /// Serializes a single named table to a JSON array string.
+ /// Returns "[]" if the table name is not recognised.
+ ///
+ public static string SerializeTable(DarkUserMemoryDatabase db, string tableName)
+ => Serializers.TryGetValue(tableName, out var serialize) ? serialize(db) : "[]";
+
+ ///
+ /// Computes only the tables that changed since the snapshot.
+ /// Use this for incremental API responses (e.g. SetUserName, GameStart).
+ ///
+ public static Dictionary Delta(Dictionary before, DarkUserMemoryDatabase db)
+ {
+ var diff = new Dictionary();
+ foreach (var (table, serialize) in Serializers)
+ {
+ var afterJson = serialize(db);
+ before.TryGetValue(table, out var beforeJson);
+ beforeJson ??= "[]";
+
+ if (afterJson == beforeJson) continue;
+
+ diff[table] = new DiffData
+ {
+ UpdateRecordsJson = afterJson,
+ DeleteKeysJson = "[]"
+ };
+ }
+ return diff;
+ }
+}
diff --git a/src/Data/UserDataStore.cs b/src/Data/UserDataStore.cs
index d56abf4..b9f4499 100644
--- a/src/Data/UserDataStore.cs
+++ b/src/Data/UserDataStore.cs
@@ -1,24 +1,139 @@
+using MariesWonderland.Models.Entities;
+using MariesWonderland.Models.Type;
+
namespace MariesWonderland.Data;
public class UserDataStore
{
- private readonly Dictionary _users = new();
+ private readonly Dictionary _users = [];
+ private readonly Dictionary _uuidToUserId = [];
+ private readonly Dictionary _sessions = [];
- public DarkUserMemoryDatabase GetOrCreate(long playerId)
+ ///
+ /// Look up or create a user by UUID. Returns the userId and whether the user is new.
+ /// For new users, seeds the initial data into their database.
+ ///
+ public (long UserId, bool IsNew) RegisterOrGetUser(string uuid)
{
- if (!_users.TryGetValue(playerId, out var db))
+ if (_uuidToUserId.TryGetValue(uuid, out var existingId))
+ return (existingId, false);
+
+ var userId = GenerateUserId();
+ _uuidToUserId[uuid] = userId;
+ var db = new DarkUserMemoryDatabase();
+ SeedInitialUserData(db, userId);
+ _users[userId] = db;
+ return (userId, true);
+ }
+
+ public UserSession CreateSession(long userId, TimeSpan ttl)
+ {
+ var key = $"session_{userId}_{Guid.NewGuid():N}";
+ var session = new UserSession(key, userId, DateTime.UtcNow.Add(ttl));
+ _sessions[key] = session;
+ return session;
+ }
+
+ public bool TryResolveSession(string sessionKey, out long userId)
+ {
+ if (_sessions.TryGetValue(sessionKey, out var session) && session.ExpiresAt > DateTime.UtcNow)
+ {
+ userId = session.UserId;
+ return true;
+ }
+ userId = 0;
+ return false;
+ }
+
+ public DarkUserMemoryDatabase GetOrCreate(long userId)
+ {
+ if (!_users.TryGetValue(userId, out var db))
{
db = new DarkUserMemoryDatabase();
- _users[playerId] = db;
+ _users[userId] = db;
}
return db;
}
- public bool TryGet(long playerId, out DarkUserMemoryDatabase db)
- => _users.TryGetValue(playerId, out db!);
+ public bool TryGet(long userId, out DarkUserMemoryDatabase db)
+ => _users.TryGetValue(userId, out db!);
- public void Set(long playerId, DarkUserMemoryDatabase db)
- => _users[playerId] = db;
+ public void Set(long userId, DarkUserMemoryDatabase db)
+ => _users[userId] = db;
public IReadOnlyDictionary All => _users;
+
+ private static long GenerateUserId()
+ {
+ // Random 19-digit positive long (range: 1e18 to long.MaxValue)
+ return Random.Shared.NextInt64(1_000_000_000_000_000_000L, long.MaxValue);
+ }
+
+ private static void SeedInitialUserData(DarkUserMemoryDatabase db, long userId)
+ {
+ var nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+
+ db.EntityIUser.Add(new EntityIUser
+ {
+ UserId = userId,
+ PlayerId = userId,
+ OsType = 2,
+ PlatformType = PlatformType.GOOGLE_PLAY_STORE,
+ UserRestrictionType = 0,
+ RegisterDatetime = nowMs,
+ GameStartDatetime = nowMs,
+ LatestVersion = 0
+ });
+
+ db.EntityIUserSetting.Add(new EntityIUserSetting
+ {
+ UserId = userId,
+ IsNotifyPurchaseAlert = false,
+ LatestVersion = 0
+ });
+
+ db.EntityIUserStatus.Add(new EntityIUserStatus
+ {
+ UserId = userId,
+ Level = 1,
+ Exp = 0,
+ StaminaMilliValue = 60000,
+ StaminaUpdateDatetime = nowMs,
+ LatestVersion = 0
+ });
+
+ db.EntityIUserProfile.Add(new EntityIUserProfile
+ {
+ UserId = userId,
+ Name = string.Empty,
+ NameUpdateDatetime = nowMs,
+ Message = string.Empty,
+ MessageUpdateDatetime = nowMs,
+ FavoriteCostumeId = 0,
+ FavoriteCostumeIdUpdateDatetime = nowMs,
+ LatestVersion = 0
+ });
+
+ db.EntityIUserLogin.Add(new EntityIUserLogin
+ {
+ UserId = userId,
+ TotalLoginCount = 1,
+ ContinualLoginCount = 1,
+ MaxContinualLoginCount = 1,
+ LastLoginDatetime = nowMs,
+ LastComebackLoginDatetime = 0,
+ LatestVersion = 0
+ });
+
+ db.EntityIUserLoginBonus.Add(new EntityIUserLoginBonus
+ {
+ UserId = userId,
+ LoginBonusId = 1,
+ CurrentPageNumber = 1,
+ CurrentStampNumber = 0,
+ LatestRewardReceiveDatetime = 0,
+ LatestVersion = 0
+ });
+ }
}
+
diff --git a/src/Data/UserSession.cs b/src/Data/UserSession.cs
new file mode 100644
index 0000000..b7ea4c1
--- /dev/null
+++ b/src/Data/UserSession.cs
@@ -0,0 +1,3 @@
+namespace MariesWonderland.Data;
+
+public record UserSession(string SessionKey, long UserId, DateTime ExpiresAt);
diff --git a/src/Extensions/GrpcContextExtensions.cs b/src/Extensions/GrpcContextExtensions.cs
new file mode 100644
index 0000000..6e47d7b
--- /dev/null
+++ b/src/Extensions/GrpcContextExtensions.cs
@@ -0,0 +1,15 @@
+using Grpc.Core;
+
+namespace MariesWonderland.Extensions;
+
+public static class GrpcContextExtensions
+{
+ public static long GetUserId(this ServerCallContext context)
+ {
+ string? value = context.RequestHeaders.GetValue("x-apb-user-id");
+ return value != null && long.TryParse(value, out long id) ? id : 0;
+ }
+
+ public static string GetSessionKey(this ServerCallContext context)
+ => context.RequestHeaders.GetValue("x-apb-session-key") ?? "";
+}
diff --git a/src/Extensions/HttpApiExtensions.cs b/src/Extensions/HttpApiExtensions.cs
index 9d0ee88..f9571b8 100644
--- a/src/Extensions/HttpApiExtensions.cs
+++ b/src/Extensions/HttpApiExtensions.cs
@@ -1,26 +1,84 @@
using MariesWonderland.Configuration;
+using MariesWonderland.Http;
using Microsoft.Extensions.Options;
+using System.Text;
namespace MariesWonderland.Extensions;
public static class HttpApiExtensions
{
+ // The URL embedded in list.bin pointing to the original CDN (must be exactly 43 ASCII bytes).
+ private static readonly byte[] ResourcesUrlOriginal =
+ Encoding.ASCII.GetBytes("https://resources.app.nierreincarnation.com");
+
public static WebApplication MapHttpApis(this WebApplication app)
{
- var options = app.Services.GetRequiredService>().Value;
- var assetDatabaseBasePath = options.Paths.AssetDatabase;
- var masterDatabaseBasePath = options.Paths.MasterDatabase;
+ ServerOptions options = app.Services.GetRequiredService>().Value;
+ string assetDatabaseBasePath = options.Paths.AssetDatabase;
+ string masterDatabaseBasePath = options.Paths.MasterDatabase;
+ string resourcesBaseUrl = options.Paths.ResourcesBaseUrl;
+
+ ILogger assetLogger = app.Services.GetRequiredService().CreateLogger();
+ AssetDatabase assetDb = new(assetDatabaseBasePath, assetLogger);
app.MapGet("/", () => "Marie's Wonderland is open for business :marie:");
// ToS. Expects the version wrapped in delimiters like "###123###".
app.MapGet("/web/static/{languagePath}/terms/termsofuse", (string languagePath) => $"Terms of ServiceTerms of Service
Language: {languagePath}
Version: ###123###
");
- // Asset Database
- app.MapGet("/v1/list/300116832/{revision}", async (string revision) =>
- await File.ReadAllBytesAsync(Path.Combine(assetDatabaseBasePath, revision, "list.bin")));
- app.MapGet("/v2/pub/a/301/v/300116832/list/{revision}", async (string revision) =>
- await File.ReadAllBytesAsync(Path.Combine(assetDatabaseBasePath, revision, "list.bin")));
+ // Asset Database — serves list.bin, rewriting the embedded CDN base URL if configured.
+ // Records which revision the client is using so subsequent asset requests resolve correctly.
+ app.MapGet("/v1/list/300116832/{revision}", async (string revision, HttpContext ctx) =>
+ {
+ string clientIp = ctx.Connection.RemoteIpAddress?.ToString() ?? "";
+ assetDb.RememberRevision(clientIp, revision);
+
+ byte[] data = await File.ReadAllBytesAsync(Path.Combine(assetDatabaseBasePath, revision, "list.bin"));
+ RewriteResourcesUrl(data, resourcesBaseUrl, app.Logger);
+ return Results.Bytes(data, "application/x-protobuf");
+ });
+
+ // Asset Bundles / Resources — resolves objectId via the list.bin index for the client's active revision.
+ // Path: /aaaaaaaaaaaaaaaaaaaaaaaa/unso-{version}-{type}/{objectId}
+ // type = "assetbundle" or "resources" (last segment of "unso-…" after splitting on '-')
+ app.MapGet("/aaaaaaaaaaaaaaaaaaaaaaaa/unso-{version}-{type}/{objectId}", (string version, string type, string objectId, HttpContext ctx) =>
+ {
+ string clientIp = ctx.Connection.RemoteIpAddress?.ToString() ?? "";
+
+ foreach (AssetCandidate candidate in assetDb.Resolve(clientIp, type, objectId))
+ {
+ FileInfo info = new(candidate.Path);
+ if (!info.Exists) continue;
+
+ // Size validation: only enforce when list.bin provided a plausible size (≥ 256 bytes).
+ if (candidate.ExpectedSize >= 256 && info.Length != candidate.ExpectedSize)
+ {
+ app.Logger.LogDebug(
+ "Asset size mismatch: objectId={ObjectId} path={Path} expected={Expected} actual={Actual} — skipping",
+ objectId, candidate.Path, candidate.ExpectedSize, info.Length);
+ continue;
+ }
+
+ // MD5 validation when the index provided a checksum.
+ if (!string.IsNullOrEmpty(candidate.ExpectedMD5))
+ {
+ string? actualMd5 = assetDb.ComputeMd5(candidate.Path, info);
+ if (actualMd5 is not null && !string.Equals(actualMd5, candidate.ExpectedMD5, StringComparison.OrdinalIgnoreCase))
+ {
+ app.Logger.LogDebug(
+ "Asset MD5 mismatch: objectId={ObjectId} path={Path} expected={Expected} actual={Actual} source={Source} — skipping",
+ objectId, candidate.Path, candidate.ExpectedMD5, actualMd5, candidate.Source);
+ continue;
+ }
+ }
+
+ // Serve the file — Results.File handles Range requests, ETags, etc.
+ return Results.File(candidate.Path, "application/octet-stream");
+ }
+
+ app.Logger.LogWarning("Asset not found: objectId={ObjectId} type={Type} clientIp={Ip}", objectId, type, clientIp);
+ return Results.NotFound();
+ });
// Master Database
app.MapMethods("/assets/release/{masterVersion}/database.bin.e", ["GET", "HEAD"], async (HttpContext ctx, string masterVersion) =>
@@ -118,7 +176,7 @@ public static class HttpApiExtensions
while (remaining > 0)
{
int toRead = (int)Math.Min(buffer.Length, remaining);
- int read = await fs.ReadAsync(buffer, 0, toRead);
+ int read = await fs.ReadAsync(buffer.AsMemory(0, toRead));
if (read == 0) break;
await ctx.Response.Body.WriteAsync(buffer.AsMemory(0, read));
remaining -= read;
@@ -151,4 +209,33 @@ public static class HttpApiExtensions
return app;
}
+
+ ///
+ /// Rewrites the CDN base URL embedded in list.bin bytes to the configured local URL.
+ /// The replacement must be exactly 43 ASCII bytes to match the original protobuf field length.
+ ///
+ private static void RewriteResourcesUrl(byte[] data, string replacementUrl, ILogger logger)
+ {
+ if (string.IsNullOrEmpty(replacementUrl))
+ return;
+
+ byte[] replacement = Encoding.ASCII.GetBytes(replacementUrl);
+ if (replacement.Length != ResourcesUrlOriginal.Length)
+ {
+ logger.LogWarning(
+ "ResourcesBaseUrl is {Length} bytes but must be exactly {Required} bytes — serving list.bin unmodified.",
+ replacement.Length, ResourcesUrlOriginal.Length);
+ return;
+ }
+
+ int idx = data.AsSpan().IndexOf(ResourcesUrlOriginal);
+ if (idx < 0)
+ {
+ logger.LogWarning("CDN URL not found in list.bin — serving unmodified.");
+ return;
+ }
+
+ replacement.CopyTo(data, idx);
+ logger.LogDebug("list.bin: rewrote resource base URL to {Url}", replacementUrl);
+ }
}
diff --git a/src/Http/AssetDatabase.cs b/src/Http/AssetDatabase.cs
new file mode 100644
index 0000000..47d1bf3
--- /dev/null
+++ b/src/Http/AssetDatabase.cs
@@ -0,0 +1,367 @@
+using System.Collections.Concurrent;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace MariesWonderland.Http;
+
+///
+/// Resolves asset bundle/resource requests by parsing list.bin protobuf indexes and info.json alias maps.
+/// Tracks the last-served list.bin revision per client IP so asset requests use the matching revision.
+///
+public sealed class AssetDatabase(string basePath, ILogger logger)
+{
+ // Lazy-initialized per-revision list.bin indexes (objectId → entry).
+ private readonly ConcurrentDictionary>> _listBinCache = new();
+
+ // Lazy-initialized per-revision info.json alias maps (fromName → alias target).
+ private readonly ConcurrentDictionary?>> _infoCache = new();
+
+ // Per-client-IP active revision (set when client fetches list.bin).
+ private readonly ConcurrentDictionary _clientRevisions = new();
+
+ // Fallback when no per-client revision is known.
+ private volatile string _lastKnownRevision = "0";
+
+ /// Records that a client fetched list.bin for the given revision.
+ public void RememberRevision(string clientIp, string revision)
+ {
+ _clientRevisions[clientIp] = revision;
+ _lastKnownRevision = revision;
+ }
+
+ ///
+ /// Resolves an asset request to ordered file-path candidates.
+ /// Caller should try each candidate in order, validating size and MD5, and serve the first valid one.
+ ///
+ public IEnumerable Resolve(string clientIp, string assetType, string objectId)
+ {
+ string revision = _clientRevisions.TryGetValue(clientIp, out string? rev) ? rev : _lastKnownRevision;
+ return ResolveForRevision(revision, assetType, objectId);
+ }
+
+ private IEnumerable ResolveForRevision(string revision, string assetType, string objectId)
+ {
+ Dictionary? index = LoadListBinIndex(revision);
+ if (index is null || !index.TryGetValue(objectId, out ListBinEntry? entry))
+ yield break;
+
+ List primaryPaths = BuildCandidatePaths(revision, assetType, entry.Path);
+ HashSet seen = [];
+
+ foreach (string path in primaryPaths)
+ {
+ if (!seen.Add(path)) continue;
+ yield return new AssetCandidate(path, revision, "list.bin", entry.MD5, entry.Size);
+ }
+
+ // info.json alias redirects: if the file name maps to a different target (possibly in another revision)
+ Dictionary? infoIndex = LoadInfoIndex(revision);
+ if (infoIndex is not null)
+ {
+ foreach (string path in primaryPaths)
+ {
+ string baseName = Path.GetFileName(path);
+ if (!infoIndex.TryGetValue(baseName, out InfoAlias? alias)) continue;
+
+ string targetRevision = alias.ToRevision ?? revision;
+ string? altPath = BuildAliasPath(path, revision, assetType, targetRevision, alias.ToName);
+ if (altPath is null) continue;
+
+ string cacheKey = $"{targetRevision}:{altPath}";
+ if (!seen.Add(cacheKey)) continue;
+
+ yield return new AssetCandidate(altPath, targetRevision, "info.json redirect", alias.MD5 ?? "", 0);
+ }
+ }
+ }
+
+ ///
+ /// Builds candidate filesystem paths for a list.bin path string.
+ /// Handles the `)` path separator and locale fallbacks (ja/ko → try en first).
+ ///
+ private List BuildCandidatePaths(string revision, string assetType, string pathStr)
+ {
+ // Safety check on raw path before any substitution
+ string rawFsPath = pathStr.Replace(')', '/');
+ if (rawFsPath.Contains("..") || Path.IsPathRooted(rawFsPath))
+ return [];
+
+ // Build path variants: locale fallbacks preferred over original
+ List variants = [];
+ if (pathStr.Contains(")ja)")) variants.Add(pathStr.Replace(")ja)", ")en)"));
+ if (pathStr.Contains(")ko)")) variants.Add(pathStr.Replace(")ko)", ")en)"));
+ variants.Add(pathStr);
+
+ List result = [];
+ HashSet seen = [];
+
+ foreach (string variant in variants)
+ {
+ string cleaned = variant.Replace(')', '/');
+ if (cleaned.Contains("..") || Path.IsPathRooted(cleaned)) continue;
+ if (!seen.Add(cleaned)) continue;
+
+ string fullPath = assetType switch
+ {
+ "assetbundle" => Path.Combine(basePath, revision, "assetbundle", cleaned + ".assetbundle"),
+ "resources" => Path.Combine(basePath, revision, "resources", cleaned),
+ _ => null!
+ };
+ if (fullPath is not null) result.Add(fullPath);
+ }
+ return result;
+ }
+
+ ///
+ /// Builds the filesystem path for an info.json alias: same directory structure, different revision + filename.
+ ///
+ private string? BuildAliasPath(string originalPath, string originalRevision, string assetType,
+ string targetRevision, string targetName)
+ {
+ string typeRoot = Path.Combine(basePath, originalRevision, assetType);
+ string rel = Path.GetRelativePath(typeRoot, originalPath);
+ if (rel.StartsWith("..") || Path.IsPathRooted(rel)) return null;
+
+ string dir = Path.GetDirectoryName(rel) ?? "";
+ return Path.Combine(basePath, targetRevision, assetType, dir, targetName);
+ }
+
+ private Dictionary? LoadListBinIndex(string revision)
+ {
+ Lazy> lazy = _listBinCache.GetOrAdd(
+ revision, rev => new Lazy>(() =>
+ {
+ string path = Path.Combine(basePath, rev, "list.bin");
+ if (!File.Exists(path)) return [];
+ byte[] data = File.ReadAllBytes(path);
+ Dictionary index = ListBinParser.Parse(data.AsSpan());
+ logger.LogDebug("Loaded list.bin for revision {Revision}: {Count} entries", rev, index.Count);
+ return index;
+ }));
+ return lazy.Value;
+ }
+
+ private Dictionary? LoadInfoIndex(string revision)
+ {
+ Lazy?> lazy = _infoCache.GetOrAdd(
+ revision, rev => new Lazy?>(() =>
+ {
+ string path = Path.Combine(basePath, rev, "info.json");
+ if (!File.Exists(path)) return null;
+ try
+ {
+ InfoJsonEntry[]? entries = JsonSerializer.Deserialize(File.ReadAllText(path));
+ if (entries is null) return null;
+
+ Dictionary result = [];
+ foreach (InfoJsonEntry e in entries)
+ {
+ if (!string.IsNullOrEmpty(e.FromName) && !string.IsNullOrEmpty(e.ToName))
+ result[e.FromName] = new InfoAlias(e.ToName, e.ToRevision?.ToString(), e.MD5);
+ }
+ return result;
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Failed to parse info.json for revision {Revision}", rev);
+ return null;
+ }
+ }));
+ return lazy.Value;
+ }
+
+ // MD5 cache: path → (size, modTimeUtcTicks, md5Hex)
+ private readonly ConcurrentDictionary _md5Cache = new();
+
+ public string? ComputeMd5(string filePath, FileInfo info)
+ {
+ long modTicks = info.LastWriteTimeUtc.Ticks;
+ if (_md5Cache.TryGetValue(filePath, out (long Size, long ModTimeTicks, string Md5) cached)
+ && cached.Size == info.Length && cached.ModTimeTicks == modTicks)
+ {
+ return cached.Md5;
+ }
+
+ try
+ {
+ byte[] hash = MD5.HashData(File.ReadAllBytes(filePath));
+ string hex = Convert.ToHexStringLower(hash);
+ _md5Cache[filePath] = (info.Length, modTicks, hex);
+ return hex;
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Failed to compute MD5 for {FilePath}", filePath);
+ return null;
+ }
+ }
+}
+
+public record ListBinEntry(string Path, long Size, string MD5);
+public record AssetCandidate(string Path, string Revision, string Source, string ExpectedMD5, long ExpectedSize);
+public record InfoAlias(string ToName, string? ToRevision, string? MD5);
+
+public sealed class InfoJsonEntry
+{
+ [JsonPropertyName("from-name")] public string FromName { get; init; } = "";
+ [JsonPropertyName("to-name")] public string ToName { get; init; } = "";
+ [JsonPropertyName("to-revision")] public int? ToRevision { get; init; }
+ [JsonPropertyName("md5")] public string? MD5 { get; init; }
+}
+
+///
+/// Parses the Octo asset management list.bin binary into a dictionary of objectId → asset entry.
+///
+///
+/// list.bin outer format — a protobuf message with mixed fields:
+///
+/// - Field 1 (varint): revision number (header metadata, skipped)
+/// - Field 2 (repeated, length-delimited): one entry per asset
+///
+/// The outer loop treats every length-delimited field as a potential entry regardless of field
+/// number, skipping non-length-delimited fields — matching the Go reference implementation.
+///
+///
+///
+/// Entry inner format:
+///
+/// - Field 1 (varint): category index (ignored)
+/// - Field 3 (string): path, using ) as directory separator
+/// - Field 4 (varint): file size in bytes
+/// - Field 5 (varint): CRC / hash (ignored)
+/// - Field 6 (varint): unknown (ignored)
+/// - Field 9 (varint): asset type index (ignored)
+/// - Field 10 (string): MD5 hex digest
+/// - Field 11 (string): objectId — 6-byte ASCII key used in asset request URLs
+/// - Field 12 (varint): timestamp (8-byte varint — requires reading up to 10 varint bytes)
+/// - Field 13 (varint): revision number (ignored)
+///
+///
+///
+///
+/// Important: varints in list.bin can be up to 8 bytes (field 12 carries a Unix timestamp
+/// in milliseconds). The reader must not bail out after 5 bytes (35-bit limit) like a naive
+/// int32 varint reader would — it must continue reading up to 10 bytes, discarding high bits
+/// that do not fit in int32. This matches Go's behaviour where int is 64-bit.
+///
+///
+internal static class ListBinParser
+{
+ public static Dictionary Parse(ReadOnlySpan data)
+ {
+ Dictionary idx = [];
+ int pos = 0;
+
+ while (pos < data.Length)
+ {
+ if (!TryReadVarint(data, ref pos, out int tag)) break;
+ int wireType = tag & 0x7;
+
+ if (wireType == 2)
+ {
+ if (!TryReadVarint(data, ref pos, out int length) || length < 0 || pos + length > data.Length)
+ break;
+
+ // Always advance past this field, whether or not the entry parses successfully.
+ int entryStart = pos;
+ if (TryParseEntry(data.Slice(pos, length), out string? objectId, out ListBinEntry entry) && objectId != null)
+ idx[objectId] = entry;
+
+ pos = entryStart + length;
+ }
+ else
+ {
+ // Skip varint / fixed-width outer fields (e.g. field 1 = revision header).
+ if (!TrySkipField(wireType, data, ref pos)) break;
+ }
+ }
+
+ return idx;
+ }
+
+ private static bool TryParseEntry(ReadOnlySpan data, out string? objectId, out ListBinEntry entry)
+ {
+ objectId = null;
+ string path = "";
+ long size = 0;
+ string md5 = "";
+ int pos = 0;
+
+ while (pos < data.Length)
+ {
+ if (!TryReadVarint(data, ref pos, out int tag)) { entry = default!; return false; }
+ int fieldNum = tag >> 3;
+ int wireType = tag & 0x7;
+
+ switch (fieldNum)
+ {
+ case 3: // path
+ if (wireType != 2 || !TryReadString(data, ref pos, out path)) { entry = default!; return false; }
+ break;
+ case 4: // size (varint)
+ if (wireType != 0 || !TryReadVarint(data, ref pos, out int sz)) { entry = default!; return false; }
+ if (sz >= 256) size = sz;
+ break;
+ case 10: // md5
+ if (wireType != 2 || !TryReadString(data, ref pos, out md5)) { entry = default!; return false; }
+ break;
+ case 11: // objectId
+ if (wireType != 2 || !TryReadString(data, ref pos, out string oid)) { entry = default!; return false; }
+ objectId = oid;
+ break;
+ default:
+ // Unknown field — skip and continue (same as Go's skipProtoField in default case)
+ if (!TrySkipField(wireType, data, ref pos)) { entry = default!; return false; }
+ break;
+ }
+ }
+
+ if (objectId is null || string.IsNullOrEmpty(path)) { entry = default!; return false; }
+ entry = new ListBinEntry(path, size, md5);
+ return true;
+ }
+
+ private static bool TryReadString(ReadOnlySpan data, ref int pos, out string value)
+ {
+ value = "";
+ if (!TryReadVarint(data, ref pos, out int length) || length < 0 || pos + length > data.Length)
+ return false;
+ value = Encoding.UTF8.GetString(data.Slice(pos, length));
+ pos += length;
+ return true;
+ }
+
+ /// Reads a protobuf varint from at , advancing pos.
+ private static bool TryReadVarint(ReadOnlySpan data, ref int pos, out int value)
+ {
+ value = 0;
+ int shift = 0;
+ while (pos < data.Length)
+ {
+ byte b = data[pos++];
+ // Accumulate into value only while bits fit in int32; still read (and discard) higher bytes.
+ if (shift < 32)
+ value |= (b & 0x7F) << shift;
+ if ((b & 0x80) == 0) return true;
+ shift += 7;
+ if (shift >= 70) return false; // max 10 bytes, same as Go
+ }
+ return false;
+ }
+
+ private static bool TrySkipField(int wireType, ReadOnlySpan data, ref int pos)
+ {
+ switch (wireType)
+ {
+ case 0: return TryReadVarint(data, ref pos, out _);
+ case 1: if (pos + 8 > data.Length) return false; pos += 8; return true;
+ case 2:
+ if (!TryReadVarint(data, ref pos, out int len) || len < 0 || pos + len > data.Length) return false;
+ pos += len; return true;
+ case 5: if (pos + 4 > data.Length) return false; pos += 4; return true;
+ default: return false;
+ }
+ }
+}
diff --git a/src/Models/Entities/EntitySUserDevice.cs b/src/Models/Entities/EntitySUserDevice.cs
new file mode 100644
index 0000000..6b76940
--- /dev/null
+++ b/src/Models/Entities/EntitySUserDevice.cs
@@ -0,0 +1,15 @@
+namespace MariesWonderland.Models.Entities;
+
+public class EntitySUserDevice
+{
+ public long UserId { get; set; }
+ public string Uuid { get; set; } = "";
+ public string AdvertisingId { get; set; } = "";
+ public bool IsTrackingEnabled { get; set; }
+ public string IdentifierForVendor { get; set; } = "";
+ public string DeviceToken { get; set; } = "";
+ public string MacAddress { get; set; } = "";
+ public string RegistrationId { get; set; } = "";
+ public long RegisteredAt { get; set; }
+ public long LastAuthAt { get; set; }
+}
diff --git a/src/Services/DataService.cs b/src/Services/DataService.cs
index d8bf5a7..4358f52 100644
--- a/src/Services/DataService.cs
+++ b/src/Services/DataService.cs
@@ -1,18 +1,18 @@
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using MariesWonderland.Configuration;
+using MariesWonderland.Data;
+using MariesWonderland.Extensions;
using MariesWonderland.Proto.Data;
using Microsoft.Extensions.Options;
namespace MariesWonderland.Services;
-public class DataService(IOptions options) : MariesWonderland.Proto.Data.DataService.DataServiceBase
+public class DataService(IOptions options, UserDataStore userDataStore)
+ : MariesWonderland.Proto.Data.DataService.DataServiceBase
{
private readonly DataOptions _data = options.Value.Data;
- private const string TablePrefix = "Entity";
- private const string TableSuffix = "Table";
-
public override Task GetLatestMasterDataVersion(Empty request, ServerCallContext context)
{
return Task.FromResult(new MasterDataGetLatestVersionResponse
@@ -23,17 +23,10 @@ public class DataService(IOptions options) : MariesWonderland.Pro
public override Task GetUserDataNameV2(Empty request, ServerCallContext context)
{
- UserDataGetNameResponseV2 response = new();
TableNameList tableNameList = new();
- var names = Directory
- .EnumerateFiles(_data.UserDataPath, "*.json")
- .Select(path =>
- {
- var name = Path.GetFileNameWithoutExtension(path); // e.g. "EntityIUserTable"
- return name.Substring(TablePrefix.Length, name.Length - TablePrefix.Length - TableSuffix.Length); // result for "EntityIUserTable" -> "IUser"
- });
+ tableNameList.TableName.AddRange(UserDataDiffBuilder.TableNames.Order());
- tableNameList.TableName.AddRange(names);
+ UserDataGetNameResponseV2 response = new();
response.TableNameList.Add(tableNameList);
return Task.FromResult(response);
@@ -41,13 +34,20 @@ public class DataService(IOptions options) : MariesWonderland.Pro
public override Task GetUserData(UserDataGetRequest request, ServerCallContext context)
{
+ long userId = context.GetUserId();
+ string sessionKey = context.GetSessionKey();
+
+ if (!userDataStore.TryResolveSession(sessionKey, out long resolvedUserId) || resolvedUserId != userId)
+ {
+ throw new RpcException(new Status(StatusCode.Unauthenticated, "Invalid or expired session."));
+ }
+
+ DarkUserMemoryDatabase userDb = userDataStore.GetOrCreate(userId);
UserDataGetResponse response = new();
- foreach (var tableName in request.TableName)
+ foreach (string tableName in request.TableName)
{
- var filePath = Path.Combine(UserDataBasePath, TablePrefix + tableName + TableSuffix + ".json");
- var jsonContent = File.ReadAllText(filePath);
- response.UserDataJson.Add(tableName, jsonContent);
+ response.UserDataJson[tableName] = UserDataDiffBuilder.SerializeTable(userDb, tableName);
}
return Task.FromResult(response);
diff --git a/src/Services/UserService.cs b/src/Services/UserService.cs
index a0f11ed..3033962 100644
--- a/src/Services/UserService.cs
+++ b/src/Services/UserService.cs
@@ -1,11 +1,17 @@
-using MariesWonderland.Proto.User;
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
+using MariesWonderland.Data;
+using MariesWonderland.Extensions;
+using MariesWonderland.Models.Entities;
+using MariesWonderland.Proto.Data;
+using MariesWonderland.Proto.User;
namespace MariesWonderland.Services;
-public class UserService : MariesWonderland.Proto.User.UserService.UserServiceBase
+public class UserService(UserDataStore store) : MariesWonderland.Proto.User.UserService.UserServiceBase
{
+ private readonly UserDataStore _store = store;
+
public override Task GetAndroidArgs(GetAndroidArgsRequest request, ServerCallContext context)
{
return Task.FromResult(new GetAndroidArgsResponse
@@ -17,13 +23,39 @@ public class UserService : MariesWonderland.Proto.User.UserService.UserServiceBa
public override Task Auth(AuthUserRequest request, ServerCallContext context)
{
- return Task.FromResult(new AuthUserResponse
+ var (userId, isNew) = _store.RegisterOrGetUser(request.Uuid);
+ var userDb = _store.GetOrCreate(userId);
+
+ var nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+ var deviceRecord = userDb.EntitySUserDevice.FirstOrDefault(d => d.UserId == userId);
+ if (deviceRecord == null)
{
- ExpireDatetime = Timestamp.FromDateTime(DateTime.UtcNow.AddDays(30)),
- UserId = 1234567890123450000,
- SessionKey = "1234567890",
- Signature = request.Signature
- });
+ deviceRecord = new EntitySUserDevice { UserId = userId };
+ userDb.EntitySUserDevice.Add(deviceRecord);
+ }
+ deviceRecord.Uuid = request.Uuid;
+ deviceRecord.AdvertisingId = request.AdvertisingId;
+ deviceRecord.IsTrackingEnabled = request.IsTrackingEnabled;
+ deviceRecord.IdentifierForVendor = request.DeviceInherent?.IdentifierForVendor ?? "";
+ deviceRecord.DeviceToken = request.DeviceInherent?.DeviceToken ?? "";
+ deviceRecord.MacAddress = request.DeviceInherent?.MacAddress ?? "";
+ deviceRecord.RegistrationId = request.DeviceInherent?.RegistrationId ?? "";
+ deviceRecord.LastAuthAt = nowMs;
+ if (isNew) deviceRecord.RegisteredAt = nowMs;
+
+ var session = _store.CreateSession(userId, TimeSpan.FromHours(24));
+ var diffData = UserDataDiffBuilder.FullDiff(userDb);
+
+ var response = new AuthUserResponse
+ {
+ SessionKey = session.SessionKey,
+ ExpireDatetime = Timestamp.FromDateTime(session.ExpiresAt),
+ Signature = request.Signature,
+ UserId = userId
+ };
+ foreach (var (k, v) in diffData) response.DiffUserData[k] = v;
+
+ return Task.FromResult(response);
}
public override Task CheckTransferSetting(Empty request, ServerCallContext context)
@@ -66,11 +98,20 @@ public class UserService : MariesWonderland.Proto.User.UserService.UserServiceBa
public override Task RegisterUser(RegisterUserRequest request, ServerCallContext context)
{
- return Task.FromResult(new RegisterUserResponse
+ // RegisterUser is the very first API called on a fresh install. It registers the device UUID
+ // and assigns a permanent userId (random 19-digit number). Subsequent launches call Auth instead.
+ var (userId, _) = _store.RegisterOrGetUser(request.Uuid);
+ DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
+ Dictionary diffData = UserDataDiffBuilder.FullDiff(userDb);
+
+ RegisterUserResponse response = new()
{
- UserId = 1234567890123450000,
- Signature = "V2UnbGxQbGF5QWdhaW5Tb21lZGF5TXJNb25zdGVyIQ=="
- });
+ UserId = userId,
+ Signature = $"sig_{userId}_{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}"
+ };
+ foreach (var (k, v) in diffData) response.DiffUserData[k] = v;
+
+ return Task.FromResult(response);
}
public override Task SetAppleAccount(SetAppleAccountRequest request, ServerCallContext context)
@@ -100,7 +141,28 @@ public class UserService : MariesWonderland.Proto.User.UserService.UserServiceBa
public override Task SetUserName(SetUserNameRequest request, ServerCallContext context)
{
- return Task.FromResult(new SetUserNameResponse());
+ long userId = context.GetUserId();
+ DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
+
+ Dictionary before = UserDataDiffBuilder.Snapshot(userDb);
+
+ EntityIUserProfile profile = userDb.EntityIUserProfile.FirstOrDefault(p => p.UserId == userId)
+ ?? AddEntity(userDb.EntityIUserProfile, new EntityIUserProfile { UserId = userId });
+
+ profile.Name = request.Name;
+ profile.NameUpdateDatetime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+
+ SetUserNameResponse response = new();
+ foreach (var (k, v) in UserDataDiffBuilder.Delta(before, userDb)) response.DiffUserData[k] = v;
+
+ return Task.FromResult(response);
+ }
+
+ /// Adds an entity to a list and returns it (convenience for inline new-entity seeding).
+ private static T AddEntity(List list, T entity)
+ {
+ list.Add(entity);
+ return entity;
}
public override Task SetUserSetting(SetUserSettingRequest request, ServerCallContext context)
@@ -110,11 +172,7 @@ public class UserService : MariesWonderland.Proto.User.UserService.UserServiceBa
public override Task TransferUser(TransferUserRequest request, ServerCallContext context)
{
- return Task.FromResult(new TransferUserResponse
- {
- UserId = 1234567890123450000,
- Signature = "V2UnbGxQbGF5QWdhaW5Tb21lZGF5TXJNb25zdGVyIQ=="
- });
+ return Task.FromResult(new TransferUserResponse());
}
public override Task TransferUserByApple(TransferUserByAppleRequest request, ServerCallContext context)
diff --git a/src/appsettings.Development.json b/src/appsettings.Development.json
index 6a78df3..cadd9ab 100644
--- a/src/appsettings.Development.json
+++ b/src/appsettings.Development.json
@@ -8,7 +8,8 @@
"Server": {
"Paths": {
"AssetDatabase": "",
- "MasterDatabase": ""
+ "MasterDatabase": "",
+ "ResourcesBaseUrl": ""
},
"Data": {
"LatestMasterDataVersion": "20240404193219",
diff --git a/src/appsettings.json b/src/appsettings.json
index 1282cb8..a318bbf 100644
--- a/src/appsettings.json
+++ b/src/appsettings.json
@@ -16,7 +16,8 @@
"Server": {
"Paths": {
"AssetDatabase": "",
- "MasterDatabase": ""
+ "MasterDatabase": "",
+ "ResourcesBaseUrl": ""
},
"Data": {
"LatestMasterDataVersion": "",