From 62d90edbeae30bb456358eb07051f526eb0a83ee Mon Sep 17 00:00:00 2001 From: BillyCool Date: Sat, 14 Mar 2026 02:09:25 +1100 Subject: [PATCH] Implement missing asset bundle endpoints and initial new user flow --- src/Configuration/ServerOptions.cs | 7 + src/Data/DarkUserMemoryDatabase.cs | 3 + src/Data/UserDataDiffBuilder.cs | 107 +++++++ src/Data/UserDataStore.cs | 131 +++++++- src/Data/UserSession.cs | 3 + src/Extensions/GrpcContextExtensions.cs | 15 + src/Extensions/HttpApiExtensions.cs | 105 ++++++- src/Http/AssetDatabase.cs | 367 +++++++++++++++++++++++ src/Models/Entities/EntitySUserDevice.cs | 15 + src/Services/DataService.cs | 34 +-- src/Services/UserService.cs | 94 ++++-- src/appsettings.Development.json | 3 +- src/appsettings.json | 3 +- 13 files changed, 833 insertions(+), 54 deletions(-) create mode 100644 src/Data/UserDataDiffBuilder.cs create mode 100644 src/Data/UserSession.cs create mode 100644 src/Extensions/GrpcContextExtensions.cs create mode 100644 src/Http/AssetDatabase.cs create mode 100644 src/Models/Entities/EntitySUserDevice.cs 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 Service

Terms 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": "",