Implement missing asset bundle endpoints and initial new user flow

This commit is contained in:
BillyCool
2026-03-14 02:09:25 +11:00
parent 48cf89f792
commit 62d90edbea
13 changed files with 833 additions and 54 deletions

View File

@@ -12,6 +12,13 @@ public sealed class PathsOptions
{
public string AssetDatabase { get; init; } = string.Empty;
public string MasterDatabase { get; init; } = string.Empty;
/// <summary>
/// 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.
/// </summary>
public string ResourcesBaseUrl { get; init; } = string.Empty;
}
public sealed class DataOptions

View File

@@ -218,4 +218,7 @@ public class DarkUserMemoryDatabase
public List<EntityIUserWebviewPanelMission> EntityIUserWebviewPanelMission { get; set; } = [];
// Server-exclusive data (EntityS* prefix): never sent to client
public List<EntitySUserDevice> EntitySUserDevice { get; set; } = [];
}

View File

@@ -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<string, Func<DarkUserMemoryDatabase, string>> Serializers;
static UserDataDiffBuilder()
{
var serializers = new Dictionary<string, Func<DarkUserMemoryDatabase, string>>();
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;
}
/// <summary>All client-visible user table names (IUser* series, excludes EntityS* and EntityM*).</summary>
public static IEnumerable<string> TableNames => Serializers.Keys;
/// <summary>
/// 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.
/// </summary>
public static Dictionary<string, string> Snapshot(DarkUserMemoryDatabase db)
{
var snapshot = new Dictionary<string, string>();
foreach (var (table, serialize) in Serializers)
{
var json = serialize(db);
if (json != "[]")
snapshot[table] = json;
}
return snapshot;
}
/// <summary>
/// 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.
/// </summary>
public static Dictionary<string, DiffData> FullDiff(DarkUserMemoryDatabase db)
{
var diff = new Dictionary<string, DiffData>();
foreach (var (table, serialize) in Serializers)
{
diff[table] = new DiffData
{
UpdateRecordsJson = serialize(db),
DeleteKeysJson = "[]"
};
}
return diff;
}
/// <summary>
/// Serializes a single named table to a JSON array string.
/// Returns "[]" if the table name is not recognised.
/// </summary>
public static string SerializeTable(DarkUserMemoryDatabase db, string tableName)
=> Serializers.TryGetValue(tableName, out var serialize) ? serialize(db) : "[]";
/// <summary>
/// Computes only the tables that changed since the snapshot.
/// Use this for incremental API responses (e.g. SetUserName, GameStart).
/// </summary>
public static Dictionary<string, DiffData> Delta(Dictionary<string, string> before, DarkUserMemoryDatabase db)
{
var diff = new Dictionary<string, DiffData>();
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;
}
}

View File

@@ -1,24 +1,139 @@
using MariesWonderland.Models.Entities;
using MariesWonderland.Models.Type;
namespace MariesWonderland.Data;
public class UserDataStore
{
private readonly Dictionary<long, DarkUserMemoryDatabase> _users = new();
private readonly Dictionary<long, DarkUserMemoryDatabase> _users = [];
private readonly Dictionary<string, long> _uuidToUserId = [];
private readonly Dictionary<string, UserSession> _sessions = [];
public DarkUserMemoryDatabase GetOrCreate(long playerId)
/// <summary>
/// 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.
/// </summary>
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<long, DarkUserMemoryDatabase> 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
});
}
}

3
src/Data/UserSession.cs Normal file
View File

@@ -0,0 +1,3 @@
namespace MariesWonderland.Data;
public record UserSession(string SessionKey, long UserId, DateTime ExpiresAt);

View File

@@ -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") ?? "";
}

View File

@@ -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<IOptions<ServerOptions>>().Value;
var assetDatabaseBasePath = options.Paths.AssetDatabase;
var masterDatabaseBasePath = options.Paths.MasterDatabase;
ServerOptions options = app.Services.GetRequiredService<IOptions<ServerOptions>>().Value;
string assetDatabaseBasePath = options.Paths.AssetDatabase;
string masterDatabaseBasePath = options.Paths.MasterDatabase;
string resourcesBaseUrl = options.Paths.ResourcesBaseUrl;
ILogger<AssetDatabase> assetLogger = app.Services.GetRequiredService<ILoggerFactory>().CreateLogger<AssetDatabase>();
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) => $"<html><head><title>Terms of Service</title></head><body><h1>Terms of Service</h1><p>Language: {languagePath}</p><p>Version: ###123###</p></body></html>");
// 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;
}
/// <summary>
/// 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.
/// </summary>
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);
}
}

367
src/Http/AssetDatabase.cs Normal file
View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
public sealed class AssetDatabase(string basePath, ILogger<AssetDatabase> logger)
{
// Lazy-initialized per-revision list.bin indexes (objectId → entry).
private readonly ConcurrentDictionary<string, Lazy<Dictionary<string, ListBinEntry>>> _listBinCache = new();
// Lazy-initialized per-revision info.json alias maps (fromName → alias target).
private readonly ConcurrentDictionary<string, Lazy<Dictionary<string, InfoAlias>?>> _infoCache = new();
// Per-client-IP active revision (set when client fetches list.bin).
private readonly ConcurrentDictionary<string, string> _clientRevisions = new();
// Fallback when no per-client revision is known.
private volatile string _lastKnownRevision = "0";
/// <summary>Records that a client fetched list.bin for the given revision.</summary>
public void RememberRevision(string clientIp, string revision)
{
_clientRevisions[clientIp] = revision;
_lastKnownRevision = revision;
}
/// <summary>
/// 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.
/// </summary>
public IEnumerable<AssetCandidate> Resolve(string clientIp, string assetType, string objectId)
{
string revision = _clientRevisions.TryGetValue(clientIp, out string? rev) ? rev : _lastKnownRevision;
return ResolveForRevision(revision, assetType, objectId);
}
private IEnumerable<AssetCandidate> ResolveForRevision(string revision, string assetType, string objectId)
{
Dictionary<string, ListBinEntry>? index = LoadListBinIndex(revision);
if (index is null || !index.TryGetValue(objectId, out ListBinEntry? entry))
yield break;
List<string> primaryPaths = BuildCandidatePaths(revision, assetType, entry.Path);
HashSet<string> 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<string, InfoAlias>? 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);
}
}
}
/// <summary>
/// Builds candidate filesystem paths for a list.bin path string.
/// Handles the `)` path separator and locale fallbacks (ja/ko → try en first).
/// </summary>
private List<string> 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<string> 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<string> result = [];
HashSet<string> 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;
}
/// <summary>
/// Builds the filesystem path for an info.json alias: same directory structure, different revision + filename.
/// </summary>
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<string, ListBinEntry>? LoadListBinIndex(string revision)
{
Lazy<Dictionary<string, ListBinEntry>> lazy = _listBinCache.GetOrAdd(
revision, rev => new Lazy<Dictionary<string, ListBinEntry>>(() =>
{
string path = Path.Combine(basePath, rev, "list.bin");
if (!File.Exists(path)) return [];
byte[] data = File.ReadAllBytes(path);
Dictionary<string, ListBinEntry> 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<string, InfoAlias>? LoadInfoIndex(string revision)
{
Lazy<Dictionary<string, InfoAlias>?> lazy = _infoCache.GetOrAdd(
revision, rev => new Lazy<Dictionary<string, InfoAlias>?>(() =>
{
string path = Path.Combine(basePath, rev, "info.json");
if (!File.Exists(path)) return null;
try
{
InfoJsonEntry[]? entries = JsonSerializer.Deserialize<InfoJsonEntry[]>(File.ReadAllText(path));
if (entries is null) return null;
Dictionary<string, InfoAlias> 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<string, (long Size, long ModTimeTicks, string Md5)> _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; }
}
/// <summary>
/// Parses the Octo asset management list.bin binary into a dictionary of objectId → asset entry.
///
/// <para>
/// <b>list.bin outer format</b> — a protobuf message with mixed fields:
/// <list type="bullet">
/// <item>Field 1 (varint): revision number (header metadata, skipped)</item>
/// <item>Field 2 (repeated, length-delimited): one entry per asset</item>
/// </list>
/// 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.
/// </para>
///
/// <para>
/// <b>Entry inner format</b>:
/// <list type="bullet">
/// <item>Field 1 (varint): category index (ignored)</item>
/// <item>Field 3 (string): path, using <c>)</c> as directory separator</item>
/// <item>Field 4 (varint): file size in bytes</item>
/// <item>Field 5 (varint): CRC / hash (ignored)</item>
/// <item>Field 6 (varint): unknown (ignored)</item>
/// <item>Field 9 (varint): asset type index (ignored)</item>
/// <item>Field 10 (string): MD5 hex digest</item>
/// <item>Field 11 (string): objectId — 6-byte ASCII key used in asset request URLs</item>
/// <item>Field 12 (varint): timestamp (8-byte varint — <b>requires reading up to 10 varint bytes</b>)</item>
/// <item>Field 13 (varint): revision number (ignored)</item>
/// </list>
/// </para>
///
/// <para>
/// <b>Important</b>: 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 <c>int</c> is 64-bit.
/// </para>
/// </summary>
internal static class ListBinParser
{
public static Dictionary<string, ListBinEntry> Parse(ReadOnlySpan<byte> data)
{
Dictionary<string, ListBinEntry> 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<byte> 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<byte> 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;
}
/// <summary>Reads a protobuf varint from <paramref name="data"/> at <paramref name="pos"/>, advancing pos.</summary>
private static bool TryReadVarint(ReadOnlySpan<byte> 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<byte> 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;
}
}
}

View File

@@ -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; }
}

View File

@@ -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<ServerOptions> options) : MariesWonderland.Proto.Data.DataService.DataServiceBase
public class DataService(IOptions<ServerOptions> 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<MasterDataGetLatestVersionResponse> GetLatestMasterDataVersion(Empty request, ServerCallContext context)
{
return Task.FromResult(new MasterDataGetLatestVersionResponse
@@ -23,17 +23,10 @@ public class DataService(IOptions<ServerOptions> options) : MariesWonderland.Pro
public override Task<UserDataGetNameResponseV2> 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<ServerOptions> options) : MariesWonderland.Pro
public override Task<UserDataGetResponse> 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);

View File

@@ -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<GetAndroidArgsResponse> 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<AuthUserResponse> 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<CheckTransferSettingResponse> CheckTransferSetting(Empty request, ServerCallContext context)
@@ -66,11 +98,20 @@ public class UserService : MariesWonderland.Proto.User.UserService.UserServiceBa
public override Task<RegisterUserResponse> 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<string, DiffData> 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<SetAppleAccountResponse> SetAppleAccount(SetAppleAccountRequest request, ServerCallContext context)
@@ -100,7 +141,28 @@ public class UserService : MariesWonderland.Proto.User.UserService.UserServiceBa
public override Task<SetUserNameResponse> SetUserName(SetUserNameRequest request, ServerCallContext context)
{
return Task.FromResult(new SetUserNameResponse());
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
Dictionary<string, string> 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);
}
/// <summary>Adds an entity to a list and returns it (convenience for inline new-entity seeding).</summary>
private static T AddEntity<T>(List<T> list, T entity)
{
list.Add(entity);
return entity;
}
public override Task<SetUserSettingResponse> SetUserSetting(SetUserSettingRequest request, ServerCallContext context)
@@ -110,11 +172,7 @@ public class UserService : MariesWonderland.Proto.User.UserService.UserServiceBa
public override Task<TransferUserResponse> TransferUser(TransferUserRequest request, ServerCallContext context)
{
return Task.FromResult(new TransferUserResponse
{
UserId = 1234567890123450000,
Signature = "V2UnbGxQbGF5QWdhaW5Tb21lZGF5TXJNb25zdGVyIQ=="
});
return Task.FromResult(new TransferUserResponse());
}
public override Task<TransferUserByAppleResponse> TransferUserByApple(TransferUserByAppleRequest request, ServerCallContext context)

View File

@@ -8,7 +8,8 @@
"Server": {
"Paths": {
"AssetDatabase": "",
"MasterDatabase": ""
"MasterDatabase": "",
"ResourcesBaseUrl": ""
},
"Data": {
"LatestMasterDataVersion": "20240404193219",

View File

@@ -16,7 +16,8 @@
"Server": {
"Paths": {
"AssetDatabase": "",
"MasterDatabase": ""
"MasterDatabase": "",
"ResourcesBaseUrl": ""
},
"Data": {
"LatestMasterDataVersion": "",