Initial commit: Ananta CBT 4229938 private server with all fixes
This commit is contained in:
commit
c84135c357
286 files changed
+2214315
No files matched your search
@@ -0,0 +1,8 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<NoWarn>$(NoWarn);CS0114;CS0108;CS8618;CS8603;CS8600;CS8604;CS0168;CS8602</NoWarn>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Ananta.SDK.Configuration;
|
||||
|
||||
public sealed record ServerEndpoint(string Host, int Port)
|
||||
{
|
||||
public override string ToString() => $"{Host}:{Port}";
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Ananta.SDK.Logging;
|
||||
|
||||
/// <summary>
|
||||
/// Process-wide runtime logs shared by the SDK and server.
|
||||
/// Run-All.ps1 supplies one run id so C# and the Node proxy append to the same console files.
|
||||
/// </summary>
|
||||
public static class RuntimeLogs
|
||||
{
|
||||
private static readonly object FileLock = new();
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
IncludeFields = true,
|
||||
WriteIndented = false,
|
||||
MaxDepth = 8,
|
||||
};
|
||||
|
||||
private static bool _initialized;
|
||||
private static bool _consoleEnabled;
|
||||
private static bool _packetEnabled;
|
||||
private static bool _includeHex;
|
||||
private static bool _includeDecoded;
|
||||
private static int _maxBodyBytes;
|
||||
private static string? _consoleLatest;
|
||||
private static string? _consoleArchive;
|
||||
private static string? _packetLatest;
|
||||
private static string? _packetArchive;
|
||||
private static TextWriter? _originalOut;
|
||||
private static TextWriter? _originalError;
|
||||
private static bool _bootstrapConsoleHidden;
|
||||
|
||||
public static void Initialize(
|
||||
string logDirectory,
|
||||
string? runId,
|
||||
bool consoleEnabled,
|
||||
bool packetEnabled,
|
||||
bool includeHex,
|
||||
bool includeDecoded,
|
||||
int maxBodyBytes)
|
||||
{
|
||||
if (_initialized) return;
|
||||
|
||||
Directory.CreateDirectory(logDirectory);
|
||||
runId = string.IsNullOrWhiteSpace(runId)
|
||||
? DateTime.Now.ToString("yyyyMMdd-HHmmss")
|
||||
: runId;
|
||||
|
||||
_consoleEnabled = consoleEnabled;
|
||||
_packetEnabled = packetEnabled;
|
||||
_includeHex = includeHex;
|
||||
_includeDecoded = includeDecoded;
|
||||
_maxBodyBytes = Math.Max(0, maxBodyBytes);
|
||||
|
||||
_consoleLatest = Environment.GetEnvironmentVariable("Ananta_CONSOLE_LOG_LATEST")
|
||||
?? Path.Combine(logDirectory, "console-latest.log");
|
||||
_consoleArchive = Environment.GetEnvironmentVariable("Ananta_CONSOLE_LOG_ARCHIVE")
|
||||
?? Path.Combine(logDirectory, $"console-{runId}.log");
|
||||
_packetLatest = Environment.GetEnvironmentVariable("Ananta_PACKET_LOG_LATEST")
|
||||
?? Path.Combine(logDirectory, "packets-latest.log");
|
||||
_packetArchive = Environment.GetEnvironmentVariable("Ananta_PACKET_LOG_ARCHIVE")
|
||||
?? Path.Combine(logDirectory, $"packets-{runId}.log");
|
||||
|
||||
// When launched without Run-All, create a fresh latest/archive pair here.
|
||||
if (Environment.GetEnvironmentVariable("Ananta_LOGS_PREPARED") != "1")
|
||||
{
|
||||
if (_consoleEnabled)
|
||||
{
|
||||
PrepareFile(_consoleLatest);
|
||||
PrepareFile(_consoleArchive);
|
||||
}
|
||||
if (_packetEnabled)
|
||||
{
|
||||
PrepareFile(_packetLatest);
|
||||
PrepareFile(_packetArchive);
|
||||
}
|
||||
}
|
||||
|
||||
_initialized = true;
|
||||
|
||||
if (_consoleEnabled)
|
||||
{
|
||||
_originalOut = Console.Out;
|
||||
_originalError = Console.Error;
|
||||
_bootstrapConsoleHidden = Environment.GetEnvironmentVariable("Ananta_HIDE_BOOT_LOG") == "1";
|
||||
if (_bootstrapConsoleHidden)
|
||||
{
|
||||
// Run-All already cleared the launcher window. Keep startup diagnostics in the
|
||||
// normal console log files without repainting the interactive console.
|
||||
Console.SetOut(new MirrorOnlyTextWriter(_originalOut.Encoding, AppendConsole));
|
||||
Console.SetError(new MirrorOnlyTextWriter(_originalError.Encoding, AppendConsole));
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.SetOut(new TeeTextWriter(_originalOut, AppendConsole));
|
||||
Console.SetError(new TeeTextWriter(_originalError, AppendConsole));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Restore normal runtime console output after boot diagnostics have completed.</summary>
|
||||
public static void EndBootstrapQuietMode()
|
||||
{
|
||||
if (!_initialized || !_consoleEnabled || !_bootstrapConsoleHidden || _originalOut is null || _originalError is null)
|
||||
return;
|
||||
|
||||
Console.SetOut(new TeeTextWriter(_originalOut, AppendConsole));
|
||||
Console.SetError(new TeeTextWriter(_originalError, AppendConsole));
|
||||
_bootstrapConsoleHidden = false;
|
||||
}
|
||||
|
||||
public static void Incoming(string scope, RpcPacketKindView kind, uint methodId, int invokeId, ReadOnlySpan<byte> body)
|
||||
=> WritePacket(scope, "C2S", kind.ToString().ToUpperInvariant(), methodId, invokeId, null, body);
|
||||
|
||||
public static void OutgoingNotify(string scope, uint methodId, ReadOnlySpan<byte> body)
|
||||
=> WritePacket(scope, "S2C", "NOTIFY", methodId, 0, null, body);
|
||||
|
||||
public static void OutgoingReturn(string scope, uint methodId, int invokeId, int error, ReadOnlySpan<byte> body)
|
||||
=> WritePacket(scope, "S2C", "RETURN", methodId, invokeId, error, body);
|
||||
|
||||
public static void RawFrame(string scope, string direction, byte mode, ReadOnlySpan<byte> payload, string note)
|
||||
{
|
||||
if (!_initialized || !_packetEnabled) return;
|
||||
AppendPacket($"{Timestamp()} [{scope}] {direction} FRAME mode=0x{mode:X2} note={note} payload={payload.Length}b");
|
||||
if (!_includeHex || payload.Length == 0) return;
|
||||
var count = _maxBodyBytes == 0 ? payload.Length : Math.Min(payload.Length, _maxBodyBytes);
|
||||
foreach (var line in HexDump(payload[..count]))
|
||||
AppendPacket($" {line}");
|
||||
if (count < payload.Length)
|
||||
AppendPacket($" ... truncated {payload.Length - count} bytes (logging.packets.maxBodyBytes={_maxBodyBytes})");
|
||||
}
|
||||
|
||||
public static void Decoded(string scope, string direction, uint methodId, Type contractType, object? value)
|
||||
{
|
||||
if (!_initialized || !_packetEnabled || !_includeDecoded) return;
|
||||
string json;
|
||||
try
|
||||
{
|
||||
json = JsonSerializer.Serialize(value, contractType, JsonOptions);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
json = $"<decode-log-failed {ex.GetType().Name}: {ex.Message}>";
|
||||
}
|
||||
|
||||
AppendPacket($"{Timestamp()} [{scope}] {direction} DECODED {RpcMethodNames.Display(methodId)} type={contractType.Name} {json}");
|
||||
}
|
||||
|
||||
private static void WritePacket(
|
||||
string scope,
|
||||
string direction,
|
||||
string kind,
|
||||
uint methodId,
|
||||
int invokeId,
|
||||
int? error,
|
||||
ReadOnlySpan<byte> body)
|
||||
{
|
||||
if (!_initialized || !_packetEnabled) return;
|
||||
|
||||
var invoke = invokeId != 0 ? $" invoke={invokeId}" : string.Empty;
|
||||
var err = error.HasValue ? $" error={error.Value}" : string.Empty;
|
||||
AppendPacket($"{Timestamp()} [{scope}] {direction} {kind} {RpcMethodNames.Display(methodId)}{invoke}{err} body={body.Length}b");
|
||||
|
||||
if (!_includeHex || body.Length == 0) return;
|
||||
var count = _maxBodyBytes == 0 ? body.Length : Math.Min(body.Length, _maxBodyBytes);
|
||||
foreach (var line in HexDump(body[..count]))
|
||||
AppendPacket($" {line}");
|
||||
if (count < body.Length)
|
||||
AppendPacket($" ... truncated {body.Length - count} bytes (logging.packets.maxBodyBytes={_maxBodyBytes})");
|
||||
}
|
||||
|
||||
private static List<string> HexDump(ReadOnlySpan<byte> body)
|
||||
{
|
||||
const int width = 16;
|
||||
var lines = new List<string>((body.Length + width - 1) / width);
|
||||
for (var offset = 0; offset < body.Length; offset += width)
|
||||
{
|
||||
var len = Math.Min(width, body.Length - offset);
|
||||
var slice = body.Slice(offset, len);
|
||||
var hex = new StringBuilder(width * 3);
|
||||
var ascii = new StringBuilder(width);
|
||||
for (var i = 0; i < width; i++)
|
||||
{
|
||||
if (i < len)
|
||||
{
|
||||
var b = slice[i];
|
||||
hex.Append(b.ToString("X2")).Append(' ');
|
||||
ascii.Append(b is >= 32 and <= 126 ? (char)b : '.');
|
||||
}
|
||||
else
|
||||
{
|
||||
hex.Append(" ");
|
||||
ascii.Append(' ');
|
||||
}
|
||||
}
|
||||
lines.Add($"{offset:X8} {hex}|{ascii}|");
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
private static string Timestamp() => DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff");
|
||||
|
||||
private static void AppendConsole(string text)
|
||||
{
|
||||
if (!_initialized || !_consoleEnabled || string.IsNullOrEmpty(text)) return;
|
||||
AppendToPair(_consoleLatest, _consoleArchive, text);
|
||||
}
|
||||
|
||||
private static void AppendPacket(string line)
|
||||
=> AppendToPair(_packetLatest, _packetArchive, line + Environment.NewLine);
|
||||
|
||||
private static void AppendToPair(string? latest, string? archive, string text)
|
||||
{
|
||||
lock (FileLock)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(latest)) AppendText(latest, text);
|
||||
if (!string.IsNullOrWhiteSpace(archive) && !string.Equals(latest, archive, StringComparison.OrdinalIgnoreCase))
|
||||
AppendText(archive, text);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AppendText(string path, string text)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!);
|
||||
using var stream = new FileStream(path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete);
|
||||
using var writer = new StreamWriter(stream, new UTF8Encoding(false));
|
||||
writer.Write(text);
|
||||
}
|
||||
|
||||
private static void PrepareFile(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path)) return;
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!);
|
||||
File.WriteAllText(path, string.Empty, new UTF8Encoding(false));
|
||||
}
|
||||
|
||||
private sealed class MirrorOnlyTextWriter(Encoding encoding, Action<string> mirror) : TextWriter
|
||||
{
|
||||
public override Encoding Encoding => encoding;
|
||||
|
||||
public override void Write(char value) => mirror(value.ToString());
|
||||
|
||||
public override void Write(string? value)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(value)) mirror(value);
|
||||
}
|
||||
|
||||
public override void WriteLine(string? value)
|
||||
=> mirror((value ?? string.Empty) + Environment.NewLine);
|
||||
|
||||
public override void WriteLine() => mirror(Environment.NewLine);
|
||||
}
|
||||
|
||||
private sealed class TeeTextWriter(TextWriter original, Action<string> mirror) : TextWriter
|
||||
{
|
||||
public override Encoding Encoding => original.Encoding;
|
||||
|
||||
public override void Write(char value)
|
||||
{
|
||||
original.Write(value);
|
||||
mirror(value.ToString());
|
||||
}
|
||||
|
||||
public override void Write(string? value)
|
||||
{
|
||||
original.Write(value);
|
||||
if (!string.IsNullOrEmpty(value)) mirror(value);
|
||||
}
|
||||
|
||||
public override void WriteLine(string? value)
|
||||
{
|
||||
original.WriteLine(value);
|
||||
mirror((value ?? string.Empty) + Environment.NewLine);
|
||||
}
|
||||
|
||||
public override void WriteLine()
|
||||
{
|
||||
original.WriteLine();
|
||||
mirror(Environment.NewLine);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Small SDK-only view so the logging layer does not depend on RPC implementation types.</summary>
|
||||
public enum RpcPacketKindView
|
||||
{
|
||||
Invoke,
|
||||
Notify,
|
||||
Return,
|
||||
}
|
||||
|
||||
/// <summary>Global names populated by RpcRouter.Name; outgoing packets can therefore log symbolic MethodId names too.</summary>
|
||||
public static class RpcMethodNames
|
||||
{
|
||||
private static readonly object Lock = new();
|
||||
private static readonly Dictionary<uint, string> Names = new();
|
||||
|
||||
public static void Register(uint methodId, string name)
|
||||
{
|
||||
lock (Lock)
|
||||
Names.TryAdd(methodId, name);
|
||||
}
|
||||
|
||||
public static string Display(uint methodId)
|
||||
{
|
||||
lock (Lock)
|
||||
return Names.TryGetValue(methodId, out var name)
|
||||
? $"{name} [0x{methodId:X8}]"
|
||||
: $"0x{methodId:X8}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
namespace Ananta.SDK.Logging;
|
||||
|
||||
public sealed class ServerLogger
|
||||
{
|
||||
private static readonly object ConsoleLock = new();
|
||||
public string Scope { get; }
|
||||
|
||||
public ServerLogger(string scope) => Scope = CompactScope(scope);
|
||||
|
||||
public void Info(string message) => Write(" ", message, PickInfoColor(message));
|
||||
public void Warn(string message) => Write("!", message, ConsoleColor.Yellow);
|
||||
public void Error(string message, Exception? ex = null)
|
||||
=> Write("X", ex is null ? message : $"{message}: {ex.GetType().Name}: {ex.Message}", ConsoleColor.Red);
|
||||
|
||||
private void Write(string level, string message, ConsoleColor color)
|
||||
{
|
||||
lock (ConsoleLock)
|
||||
{
|
||||
var previous = Console.ForegroundColor;
|
||||
try
|
||||
{
|
||||
Console.ForegroundColor = color;
|
||||
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [{Scope}] {level} {message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.ForegroundColor = previous;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static ConsoleColor PickInfoColor(string message)
|
||||
{
|
||||
// Wire direction is the most common thing you scan while debugging.
|
||||
if (message.Contains("-> rpc", StringComparison.Ordinal) ||
|
||||
message.Contains("-> ntf", StringComparison.Ordinal))
|
||||
return ConsoleColor.Cyan;
|
||||
if (message.Contains("<- ret", StringComparison.Ordinal))
|
||||
return ConsoleColor.Green;
|
||||
|
||||
// High-level gameplay categories.
|
||||
if (message.Contains("[SWITCH]", StringComparison.Ordinal)) return ConsoleColor.Magenta;
|
||||
if (message.Contains("[WEB]", StringComparison.Ordinal)) return ConsoleColor.Cyan;
|
||||
if (message.Contains("[CAPABILITY]", StringComparison.Ordinal)) return ConsoleColor.DarkCyan;
|
||||
if (message.Contains("[COMBAT]", StringComparison.Ordinal)) return ConsoleColor.DarkYellow;
|
||||
if (message.Contains("[WORLD]", StringComparison.Ordinal)) return ConsoleColor.Blue;
|
||||
if (message.Contains("[PLAYER]", StringComparison.Ordinal)) return ConsoleColor.Green;
|
||||
if (message.Contains("[MOVE]", StringComparison.Ordinal)) return ConsoleColor.Gray;
|
||||
if (message.Contains("[PAUSE]", StringComparison.Ordinal)) return ConsoleColor.DarkYellow;
|
||||
|
||||
// Connection lifecycle.
|
||||
if (message.TrimStart().StartsWith("+", StringComparison.Ordinal)) return ConsoleColor.Green;
|
||||
if (message.TrimStart().StartsWith("-", StringComparison.Ordinal)) return ConsoleColor.DarkGray;
|
||||
|
||||
return ConsoleColor.Gray;
|
||||
}
|
||||
|
||||
private static string CompactScope(string scope)
|
||||
{
|
||||
var suffix = string.Empty;
|
||||
var colon = scope.IndexOf(':');
|
||||
if (colon >= 0)
|
||||
{
|
||||
suffix = scope[colon..];
|
||||
scope = scope[..colon];
|
||||
}
|
||||
|
||||
return scope switch
|
||||
{
|
||||
"login-0" => "L0" + suffix,
|
||||
"login-1" => "L1" + suffix,
|
||||
"game" => "G" + suffix,
|
||||
"router" => "R" + suffix,
|
||||
_ when scope.StartsWith("router") => "R" + suffix,
|
||||
_ => scope + suffix,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Ananta.SDK.Network;
|
||||
|
||||
public readonly record struct Frame(byte Mode, byte[] Payload);
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace Ananta.SDK.Network;
|
||||
|
||||
public static class FrameCodec
|
||||
{
|
||||
// Public/plain overload retained for handshake/debug compatibility.
|
||||
public static Task<Frame?> ReadFrameAsync(NetworkStream stream, CancellationToken token)
|
||||
=> ReadFrameAsync(stream, null, token);
|
||||
|
||||
internal static async Task<Frame?> ReadFrameAsync(NetworkStream stream, UxChaCha8? cryptor, CancellationToken token)
|
||||
{
|
||||
// UXStreamSocket decrypts the first five bytes (length + mode) as one
|
||||
// continuous stream, then decrypts the payload with the SAME cryptor state.
|
||||
var head = new byte[5];
|
||||
if (!await ReadExactAsync(stream, head, token)) return null;
|
||||
var rawHead = Convert.ToHexString(head);
|
||||
cryptor?.XorInPlace(head);
|
||||
|
||||
var payloadLength = BinaryPrimitives.ReadInt32LittleEndian(head.AsSpan(0, 4));
|
||||
if (payloadLength < 0 || payloadLength > 8 * 1024 * 1024)
|
||||
{
|
||||
// Raw bytes kept on purpose: a frame sent before the peer installed
|
||||
// its cryptor arrives as plaintext and fails this check.
|
||||
Console.Error.WriteLine($"[FRAME-DBG] rawHead={rawHead} decryptedLen={payloadLength} mode={head[4]}");
|
||||
throw new InvalidDataException($"Bad frame payload length after decrypt: {payloadLength}");
|
||||
}
|
||||
|
||||
var payload = new byte[payloadLength];
|
||||
if (payloadLength != 0)
|
||||
{
|
||||
if (!await ReadExactAsync(stream, payload, token)) return null;
|
||||
var rawPayloadHead = Convert.ToHexString(payload.AsSpan(0, Math.Min(16, payload.Length)));
|
||||
cryptor?.XorInPlace(payload);
|
||||
Console.Error.WriteLine($"[FRAME-DBG] ok len={payloadLength} mode={head[4]} rawPayloadHead={rawPayloadHead} decHead={Convert.ToHexString(head)}");
|
||||
}
|
||||
|
||||
return new Frame(head[4], payload);
|
||||
}
|
||||
|
||||
// Public/plain overload retained for the plaintext handshake reply.
|
||||
public static Task WriteFrameAsync(NetworkStream stream, byte mode, ReadOnlyMemory<byte> payload, CancellationToken token)
|
||||
=> WriteFrameAsync(stream, mode, payload, null, token);
|
||||
|
||||
internal static async Task WriteFrameAsync(
|
||||
NetworkStream stream,
|
||||
byte mode,
|
||||
ReadOnlyMemory<byte> payload,
|
||||
UxChaCha8? cryptor,
|
||||
CancellationToken token)
|
||||
{
|
||||
// Client Send() constructs [LE payloadLength][mode][payload] and, once
|
||||
// CryptorWrite exists, encrypts that full range in one continuous call.
|
||||
var frame = new byte[5 + payload.Length];
|
||||
BinaryPrimitives.WriteInt32LittleEndian(frame.AsSpan(0, 4), payload.Length);
|
||||
frame[4] = mode;
|
||||
payload.Span.CopyTo(frame.AsSpan(5));
|
||||
|
||||
cryptor?.XorInPlace(frame);
|
||||
|
||||
await stream.WriteAsync(frame, token);
|
||||
await stream.FlushAsync(token);
|
||||
}
|
||||
|
||||
private static async Task<bool> ReadExactAsync(NetworkStream stream, byte[] buffer, CancellationToken token)
|
||||
{
|
||||
var offset = 0;
|
||||
while (offset < buffer.Length)
|
||||
{
|
||||
var read = await stream.ReadAsync(buffer.AsMemory(offset, buffer.Length - offset), token);
|
||||
if (read == 0) return false;
|
||||
offset += read;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Ananta.SDK.Logging;
|
||||
using Ananta.SDK.Rpc;
|
||||
|
||||
namespace Ananta.SDK.Network;
|
||||
|
||||
public sealed class TcpServer
|
||||
{
|
||||
private readonly string _scope;
|
||||
private readonly IPEndPoint _endpoint;
|
||||
private readonly RpcRouter _router;
|
||||
private readonly Func<TcpSession, Frame, CancellationToken, Task>? _customFrameHandler;
|
||||
private readonly ServerLogger _log;
|
||||
|
||||
public TcpServer(string scope, string host, int port, RpcRouter router, Func<TcpSession, Frame, CancellationToken, Task>? customFrameHandler = null)
|
||||
{
|
||||
_scope = scope;
|
||||
_endpoint = new IPEndPoint(IPAddress.Parse(host), port);
|
||||
_router = router;
|
||||
_customFrameHandler = customFrameHandler;
|
||||
_log = new ServerLogger(scope);
|
||||
}
|
||||
|
||||
public async Task RunAsync(CancellationToken token)
|
||||
{
|
||||
var listener = new TcpListener(_endpoint);
|
||||
listener.Start();
|
||||
_log.Info($"listening at {_endpoint}");
|
||||
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
var client = await listener.AcceptTcpClientAsync(token);
|
||||
_ = Task.Run(() => HandleClientAsync(client, token), token);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleClientAsync(TcpClient client, CancellationToken token)
|
||||
{
|
||||
var session = new TcpSession(client, _scope);
|
||||
session.Log.Info($"+ {client.Client.RemoteEndPoint}");
|
||||
|
||||
try
|
||||
{
|
||||
while (!token.IsCancellationRequested && client.Connected)
|
||||
{
|
||||
var frame = await session.ReadFrameAsync(token);
|
||||
if (frame is null) break;
|
||||
|
||||
if (frame.Value.Mode == 0x02)
|
||||
{
|
||||
session.Log.Info($"HS {frame.Value.Payload.Length}b");
|
||||
await session.SendHandshakeAsync(frame.Value.Payload, token);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_customFrameHandler is not null)
|
||||
{
|
||||
await _customFrameHandler(session, frame.Value, token);
|
||||
continue;
|
||||
}
|
||||
|
||||
await _router.DispatchAsync(session, frame.Value, token);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
session.Log.Error("session error", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
session.Log.Info("-");
|
||||
session.Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography;
|
||||
using Ananta.SDK.Logging;
|
||||
using Ananta.SDK.Rpc;
|
||||
|
||||
namespace Ananta.SDK.Network;
|
||||
|
||||
public sealed class TcpSession
|
||||
{
|
||||
private readonly SemaphoreSlim _sendLock = new(1, 1);
|
||||
private UxChaCha8? _readCryptor;
|
||||
private UxChaCha8? _writeCryptor;
|
||||
|
||||
public string Id { get; } = Guid.NewGuid().ToString("N")[..6];
|
||||
public TcpClient Client { get; }
|
||||
public NetworkStream Stream { get; }
|
||||
public ServerLogger Log { get; }
|
||||
public Dictionary<string, object> Items { get; } = new();
|
||||
public bool CryptReady => _readCryptor is not null && _writeCryptor is not null;
|
||||
|
||||
public TcpSession(TcpClient client, string scope)
|
||||
{
|
||||
Client = client;
|
||||
Stream = client.GetStream();
|
||||
Log = new ServerLogger($"{scope}:{Id}");
|
||||
}
|
||||
|
||||
public Task<Frame?> ReadFrameAsync(CancellationToken token)
|
||||
=> FrameCodec.ReadFrameAsync(Stream, _readCryptor, token);
|
||||
|
||||
public async Task SendHandshakeAsync(ReadOnlyMemory<byte> clientHandshake, CancellationToken token)
|
||||
{
|
||||
if (clientHandshake.Length < 20)
|
||||
throw new InvalidDataException($"4229938 C2S handshake is only {clientHandshake.Length} bytes");
|
||||
|
||||
// C2S handshake layout: 4229938 SendHandShakeFrontEnd writes
|
||||
// magic(4) + the plain 16-byte UXAES.Generate() key at [4..20)
|
||||
// (its UXRSA.Encrypt call is patched out client-side), padded to 260.
|
||||
byte[] aesKey = clientHandshake.Slice(4, 16).ToArray();
|
||||
Log.Info($"HS raw[0..32]={Convert.ToHexString(clientHandshake.Slice(0, 32).ToArray())}");
|
||||
|
||||
// Decrypted S2C handshake blob expected by TrySetupChaChaOnHandshake:
|
||||
// [0..32) = client's write key => server READ key
|
||||
// [32..64) = client's read key => server WRITE key
|
||||
// [64..76) = shared 96-bit nonce
|
||||
// Empirically verified against 4229938: the client encrypts its frames
|
||||
// with blob[0..32) (FrameCodec raw-head capture + keystream match).
|
||||
byte[] clientWriteKey = RandomNumberGenerator.GetBytes(32);
|
||||
byte[] clientReadKey = RandomNumberGenerator.GetBytes(32);
|
||||
byte[] streamNonce = RandomNumberGenerator.GetBytes(12);
|
||||
byte[] chaChaHead = new byte[76];
|
||||
clientWriteKey.CopyTo(chaChaHead, 0);
|
||||
clientReadKey.CopyTo(chaChaHead, 32);
|
||||
streamNonce.CopyTo(chaChaHead, 64);
|
||||
|
||||
byte[] aesNonce = RandomNumberGenerator.GetBytes(12);
|
||||
byte[] cipher = new byte[76];
|
||||
byte[] tag = new byte[16];
|
||||
using (var aes = new AesGcm(aesKey, 16))
|
||||
aes.Encrypt(aesNonce, chaChaHead, cipher, tag);
|
||||
|
||||
byte[] payload = new byte[368];
|
||||
BitConverter.GetBytes(1).CopyTo(payload, 0);
|
||||
BitConverter.GetBytes(10).CopyTo(payload, 4);
|
||||
aesNonce.CopyTo(payload, 8);
|
||||
cipher.CopyTo(payload, 20);
|
||||
tag.CopyTo(payload, 96);
|
||||
// payload[112..368] is the RSA-2048 signature field. The client's
|
||||
// UXRSA.Verify result branch is patched out to accept the local
|
||||
// unsigned handshake.
|
||||
|
||||
await _sendLock.WaitAsync(token);
|
||||
try
|
||||
{
|
||||
// The handshake reply itself is plaintext. Encryption becomes active
|
||||
// only for the next frame in each direction.
|
||||
await FrameCodec.WriteFrameAsync(Stream, 0x01, payload, cryptor: null, token: token);
|
||||
_readCryptor = new UxChaCha8(clientWriteKey, streamNonce);
|
||||
_writeCryptor = new UxChaCha8(clientReadKey, streamNonce);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_sendLock.Release();
|
||||
}
|
||||
|
||||
Log.Info($"HS keys clientWrite={Convert.ToHexString(clientWriteKey)} clientRead={Convert.ToHexString(clientReadKey)} nonce={Convert.ToHexString(streamNonce)} aes={Convert.ToHexString(aesKey)}");
|
||||
|
||||
Log.Info("HS ok -> UXChaCha8 enabled");
|
||||
}
|
||||
|
||||
public Task SendFrameAsync(byte mode, byte[] body, CancellationToken token)
|
||||
=> WriteEncryptedFrameAsync(mode, body, token);
|
||||
|
||||
public Task NotifyAsync(uint methodId, byte[] body, CancellationToken token)
|
||||
{
|
||||
RuntimeLogs.OutgoingNotify(Log.Scope, methodId, body);
|
||||
return WriteEncryptedFrameAsync(0x09, RpcPacketWriter.WriteNotify(methodId, body), token);
|
||||
}
|
||||
|
||||
public Task ReturnAsync(uint methodId, int invokeId, int err, byte[] body, CancellationToken token)
|
||||
{
|
||||
Log.Info($"<- ret {RpcMethodNames.Display(methodId)} #{invokeId} e={err} {body.Length}b");
|
||||
RuntimeLogs.OutgoingReturn(Log.Scope, methodId, invokeId, err, body);
|
||||
return WriteEncryptedFrameAsync(0x09, RpcPacketWriter.WriteReturn(methodId, invokeId, err, body), token);
|
||||
}
|
||||
|
||||
private async Task WriteEncryptedFrameAsync(byte mode, ReadOnlyMemory<byte> body, CancellationToken token)
|
||||
{
|
||||
await _sendLock.WaitAsync(token);
|
||||
try
|
||||
{
|
||||
await FrameCodec.WriteFrameAsync(Stream, mode, body, _writeCryptor, token);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_sendLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public void Close()
|
||||
{
|
||||
try { Client.Close(); } catch { }
|
||||
_sendLock.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Numerics;
|
||||
|
||||
namespace Ananta.SDK.Network;
|
||||
|
||||
/// <summary>
|
||||
/// Exact stream cipher used by the 4229938 UXNetwork client.
|
||||
/// It is ChaCha8-shaped, but the four sigma constants are client-specific.
|
||||
/// </summary>
|
||||
internal sealed class UxChaCha8
|
||||
{
|
||||
// GameAssembly.dll 4229938 / UXChaCha8Impl::.ctor
|
||||
// NOTE: these deliberately differ from standard "expand 32-byte k".
|
||||
private const uint Sigma0 = 0x60707865;
|
||||
private const uint Sigma1 = 0x3321646e;
|
||||
private const uint Sigma2 = 0x79622d32;
|
||||
private const uint Sigma3 = 0x6b206e74;
|
||||
|
||||
private readonly uint[] _state = new uint[16];
|
||||
private readonly byte[] _keystream = new byte[64];
|
||||
private int _keystreamPos = 64;
|
||||
|
||||
public UxChaCha8(ReadOnlySpan<byte> key, ReadOnlySpan<byte> nonce)
|
||||
{
|
||||
if (key.Length != 32)
|
||||
throw new ArgumentException("UXChaCha8 key must be 32 bytes.", nameof(key));
|
||||
if (nonce.Length != 12)
|
||||
throw new ArgumentException("UXChaCha8 nonce must be 12 bytes.", nameof(nonce));
|
||||
|
||||
_state[0] = Sigma0;
|
||||
_state[1] = Sigma1;
|
||||
_state[2] = Sigma2;
|
||||
_state[3] = Sigma3;
|
||||
|
||||
for (var i = 0; i < 8; i++)
|
||||
_state[4 + i] = BinaryPrimitives.ReadUInt32LittleEndian(key.Slice(i * 4, 4));
|
||||
|
||||
// Client constructor sets word 12 to zero and uses a 96-bit nonce.
|
||||
_state[12] = 0;
|
||||
_state[13] = BinaryPrimitives.ReadUInt32LittleEndian(nonce.Slice(0, 4));
|
||||
_state[14] = BinaryPrimitives.ReadUInt32LittleEndian(nonce.Slice(4, 4));
|
||||
_state[15] = BinaryPrimitives.ReadUInt32LittleEndian(nonce.Slice(8, 4));
|
||||
}
|
||||
|
||||
public void XorInPlace(Span<byte> data)
|
||||
{
|
||||
var offset = 0;
|
||||
while (offset < data.Length)
|
||||
{
|
||||
if (_keystreamPos == 64)
|
||||
{
|
||||
GenerateBlock();
|
||||
_keystreamPos = 0;
|
||||
}
|
||||
|
||||
var take = Math.Min(64 - _keystreamPos, data.Length - offset);
|
||||
for (var i = 0; i < take; i++)
|
||||
data[offset + i] ^= _keystream[_keystreamPos + i];
|
||||
|
||||
offset += take;
|
||||
_keystreamPos += take;
|
||||
}
|
||||
}
|
||||
|
||||
private void GenerateBlock()
|
||||
{
|
||||
Span<uint> x = stackalloc uint[16];
|
||||
_state.AsSpan().CopyTo(x);
|
||||
|
||||
// UXChaCha8 is 8 rounds = four column+diagonal double-rounds.
|
||||
for (var i = 0; i < 4; i++)
|
||||
{
|
||||
QuarterRound(ref x[0], ref x[4], ref x[8], ref x[12]);
|
||||
QuarterRound(ref x[1], ref x[5], ref x[9], ref x[13]);
|
||||
QuarterRound(ref x[2], ref x[6], ref x[10], ref x[14]);
|
||||
QuarterRound(ref x[3], ref x[7], ref x[11], ref x[15]);
|
||||
|
||||
QuarterRound(ref x[0], ref x[5], ref x[10], ref x[15]);
|
||||
QuarterRound(ref x[1], ref x[6], ref x[11], ref x[12]);
|
||||
QuarterRound(ref x[2], ref x[7], ref x[8], ref x[13]);
|
||||
QuarterRound(ref x[3], ref x[4], ref x[9], ref x[14]);
|
||||
}
|
||||
|
||||
for (var i = 0; i < 16; i++)
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(_keystream.AsSpan(i * 4, 4), unchecked(x[i] + _state[i]));
|
||||
|
||||
// Client increments only the 32-bit block counter (state[12]).
|
||||
_state[12] = unchecked(_state[12] + 1);
|
||||
}
|
||||
|
||||
private static void QuarterRound(ref uint a, ref uint b, ref uint c, ref uint d)
|
||||
{
|
||||
a = unchecked(a + b);
|
||||
d ^= a;
|
||||
d = BitOperations.RotateLeft(d, 16);
|
||||
|
||||
c = unchecked(c + d);
|
||||
b ^= c;
|
||||
b = BitOperations.RotateLeft(b, 12);
|
||||
|
||||
a = unchecked(a + b);
|
||||
d ^= a;
|
||||
d = BitOperations.RotateLeft(d, 8);
|
||||
|
||||
c = unchecked(c + d);
|
||||
b ^= c;
|
||||
b = BitOperations.RotateLeft(b, 7);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using System.Reflection;
|
||||
|
||||
namespace Ananta.SDK.Rpc;
|
||||
|
||||
/// <summary>Scans [Handler] methods and registers them in RpcRouter.</summary>
|
||||
public static class AttributedHandlerRegistry
|
||||
{
|
||||
public static RpcRouter Build(string scope, params object[] targets)
|
||||
{
|
||||
var router = new RpcRouter(scope);
|
||||
foreach (var target in targets) Register(router, target);
|
||||
return router;
|
||||
}
|
||||
|
||||
public static void Register(RpcRouter router, object target)
|
||||
=> RegisterCore(router, target, allowedMethodIds: null);
|
||||
|
||||
/// <summary>
|
||||
/// Registers only explicitly allowed method ids. This is used by build-4229938 minimal mode so
|
||||
/// legacy feature handlers stay compiled for reference but cannot emit old-build world/story data.
|
||||
/// </summary>
|
||||
public static int RegisterSelected(RpcRouter router, object target, IReadOnlySet<uint> allowedMethodIds)
|
||||
=> RegisterCore(router, target, allowedMethodIds);
|
||||
|
||||
private static int RegisterCore(RpcRouter router, object target, IReadOnlySet<uint>? allowedMethodIds)
|
||||
{
|
||||
var type = target as Type ?? target.GetType();
|
||||
var instance = target is Type ? null : target;
|
||||
var registeredMethodIds = new HashSet<uint>();
|
||||
|
||||
foreach (var method in type.GetMethods(BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic))
|
||||
{
|
||||
var attrs = method.GetCustomAttributes<HandlerAttribute>(inherit: false).ToArray();
|
||||
if (attrs.Length == 0) continue;
|
||||
ValidateSignature(method);
|
||||
|
||||
foreach (var attr in attrs)
|
||||
{
|
||||
if (allowedMethodIds is not null && !allowedMethodIds.Contains(attr.MethodId))
|
||||
continue;
|
||||
|
||||
router.Name(attr.MethodId, $"{type.Name}.{method.Name}");
|
||||
Func<RpcContext, Task> invoke = ctx => InvokeAsync(instance, method, ctx);
|
||||
if (attr.PacketKind is HandlerPacketKind.Both or HandlerPacketKind.Invoke)
|
||||
router.Add(new AttributedInvokeHandler(attr.MethodId, invoke));
|
||||
if (attr.PacketKind is HandlerPacketKind.Both or HandlerPacketKind.Notify)
|
||||
router.Add(new AttributedNotifyHandler(attr.MethodId, invoke));
|
||||
registeredMethodIds.Add(attr.MethodId);
|
||||
}
|
||||
}
|
||||
|
||||
return registeredMethodIds.Count;
|
||||
}
|
||||
|
||||
private static void ValidateSignature(MethodInfo method)
|
||||
{
|
||||
var p = method.GetParameters();
|
||||
if (p.Length != 2 || p[0].ParameterType != typeof(Connection) || p[1].ParameterType != typeof(UxRpcMessage))
|
||||
throw new InvalidOperationException($"[Handler] {method.DeclaringType?.FullName}.{method.Name} must be (Connection, UxRpcMessage).");
|
||||
|
||||
if (method.ReturnType != typeof(void) && method.ReturnType != typeof(Task) && method.ReturnType != typeof(ValueTask))
|
||||
throw new InvalidOperationException($"[Handler] {method.DeclaringType?.FullName}.{method.Name} must return void, Task or ValueTask.");
|
||||
}
|
||||
|
||||
private static async Task InvokeAsync(object? instance, MethodInfo method, RpcContext ctx)
|
||||
{
|
||||
var conn = new Connection(ctx.Session, ctx.CancellationToken);
|
||||
var msg = new UxRpcMessage(ctx);
|
||||
try
|
||||
{
|
||||
var result = method.Invoke(instance, new object[] { conn, msg });
|
||||
if (result is Task task) await task;
|
||||
else if (result is ValueTask valueTask) await valueTask;
|
||||
}
|
||||
catch (TargetInvocationException ex) when (ex.InnerException is not null)
|
||||
{
|
||||
throw ex.InnerException;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class AttributedInvokeHandler : IRpcHandler
|
||||
{
|
||||
private readonly Func<RpcContext, Task> _handler;
|
||||
public uint MethodId { get; }
|
||||
public AttributedInvokeHandler(uint methodId, Func<RpcContext, Task> handler) { MethodId = methodId; _handler = handler; }
|
||||
public Task HandleAsync(RpcContext ctx) => _handler(ctx);
|
||||
}
|
||||
|
||||
private sealed class AttributedNotifyHandler : INotifyHandler
|
||||
{
|
||||
private readonly Func<RpcContext, Task> _handler;
|
||||
public uint MethodId { get; }
|
||||
public AttributedNotifyHandler(uint methodId, Func<RpcContext, Task> handler) { MethodId = methodId; _handler = handler; }
|
||||
public Task HandleAsync(RpcContext ctx) => _handler(ctx);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using Ananta.SDK.Logging;
|
||||
using Ananta.SDK.Network;
|
||||
using Ananta.SDK.Serialization;
|
||||
|
||||
namespace Ananta.SDK.Rpc;
|
||||
|
||||
/// <summary>
|
||||
/// Small handler-facing wrapper around TcpSession. Prefer typed Return/Notify overloads;
|
||||
/// Raw methods exist only for packets whose contract has not been described yet.
|
||||
/// </summary>
|
||||
public sealed class Connection
|
||||
{
|
||||
private readonly TcpSession _session;
|
||||
private readonly CancellationToken _token;
|
||||
|
||||
internal Connection(TcpSession session, CancellationToken token)
|
||||
{
|
||||
_session = session;
|
||||
_token = token;
|
||||
}
|
||||
|
||||
public ServerLogger Log => _session.Log;
|
||||
public TcpSession Session => _session;
|
||||
|
||||
public Task ReturnEmptyOkAsync(UxRpcMessage request)
|
||||
=> ReturnEmptyAsync(request);
|
||||
|
||||
public Task ReturnEmptyAsync(UxRpcMessage request, int error = 0)
|
||||
=> request.IsInvoke
|
||||
? _session.ReturnAsync(request.MethodId, request.InvokeId, error, Array.Empty<byte>(), _token)
|
||||
: Task.CompletedTask;
|
||||
|
||||
public Task ReturnAsync<T>(UxRpcMessage request, T body, int error = 0)
|
||||
{
|
||||
if (!request.IsInvoke) return Task.CompletedTask;
|
||||
var bytes = UxSerializer.Serialize(body);
|
||||
var send = _session.ReturnAsync(request.MethodId, request.InvokeId, error, bytes, _token);
|
||||
RuntimeLogs.Decoded(_session.Log.Scope, "S2C", request.MethodId, typeof(T), body);
|
||||
return send;
|
||||
}
|
||||
|
||||
public Task ReturnRawAsync(UxRpcMessage request, byte[] body, int error = 0)
|
||||
=> request.IsInvoke
|
||||
? _session.ReturnAsync(request.MethodId, request.InvokeId, error, body, _token)
|
||||
: Task.CompletedTask;
|
||||
|
||||
public Task NotifyAsync<T>(uint methodId, T body)
|
||||
{
|
||||
var bytes = UxSerializer.Serialize(body);
|
||||
var send = _session.NotifyAsync(methodId, bytes, _token);
|
||||
RuntimeLogs.Decoded(_session.Log.Scope, "S2C", methodId, typeof(T), body);
|
||||
return send;
|
||||
}
|
||||
|
||||
public Task NotifyRawAsync(uint methodId, byte[] body)
|
||||
=> _session.NotifyAsync(methodId, body, _token);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace Ananta.SDK.Rpc;
|
||||
|
||||
public enum HandlerPacketKind
|
||||
{
|
||||
Both,
|
||||
Invoke,
|
||||
Notify
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Declares an incoming RPC endpoint. Method ids stay build-specific constants; the dispatcher is generic.
|
||||
/// By default a handler accepts both invoke and notify forms. Return helpers are safe no-ops for notifications.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = false)]
|
||||
public sealed class HandlerAttribute : Attribute
|
||||
{
|
||||
public uint MethodId { get; }
|
||||
public HandlerPacketKind PacketKind { get; }
|
||||
|
||||
public HandlerAttribute(uint methodId, HandlerPacketKind packetKind = HandlerPacketKind.Both)
|
||||
{
|
||||
MethodId = methodId;
|
||||
PacketKind = packetKind;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Ananta.SDK.Rpc;
|
||||
|
||||
public interface INotifyHandler
|
||||
{
|
||||
uint MethodId { get; }
|
||||
Task HandleAsync(RpcContext ctx);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Ananta.SDK.Rpc;
|
||||
|
||||
public interface IRpcHandler
|
||||
{
|
||||
uint MethodId { get; }
|
||||
Task HandleAsync(RpcContext ctx);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using Ananta.SDK.Logging;
|
||||
using Ananta.SDK.Network;
|
||||
using Ananta.SDK.Serialization;
|
||||
|
||||
namespace Ananta.SDK.Rpc;
|
||||
|
||||
public sealed class RpcContext
|
||||
{
|
||||
public TcpSession Session { get; }
|
||||
public RpcPacket Packet { get; }
|
||||
public CancellationToken CancellationToken { get; }
|
||||
|
||||
public uint MethodId => Packet.MethodId;
|
||||
public int InvokeId => Packet.InvokeId;
|
||||
public byte[] Body => Packet.Body;
|
||||
|
||||
public RpcContext(TcpSession session, RpcPacket packet, CancellationToken cancellationToken)
|
||||
{
|
||||
Session = session;
|
||||
Packet = packet;
|
||||
CancellationToken = cancellationToken;
|
||||
}
|
||||
|
||||
public Task ReturnAsync(byte[] body, int err = 0)
|
||||
=> Session.ReturnAsync(MethodId, InvokeId, err, body, CancellationToken);
|
||||
|
||||
public Task ReturnEmptyOkAsync()
|
||||
=> ReturnAsync(Array.Empty<byte>());
|
||||
|
||||
public Task ReturnAsync<T>(T body, int err = 0)
|
||||
{
|
||||
var bytes = UxSerializer.Serialize(body);
|
||||
var send = ReturnAsync(bytes, err);
|
||||
RuntimeLogs.Decoded(Session.Log.Scope, "S2C", MethodId, typeof(T), body);
|
||||
return send;
|
||||
}
|
||||
|
||||
public Task NotifyAsync(uint methodId, byte[] body)
|
||||
=> Session.NotifyAsync(methodId, body, CancellationToken);
|
||||
|
||||
public Task NotifyAsync<T>(uint methodId, T body)
|
||||
{
|
||||
var bytes = UxSerializer.Serialize(body);
|
||||
var send = NotifyAsync(methodId, bytes);
|
||||
RuntimeLogs.Decoded(Session.Log.Scope, "S2C", methodId, typeof(T), body);
|
||||
return send;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Ananta.SDK.Rpc;
|
||||
|
||||
public enum RpcPacketKind
|
||||
{
|
||||
Unknown,
|
||||
Invoke,
|
||||
Notify,
|
||||
Return
|
||||
}
|
||||
|
||||
public sealed record RpcPacket(RpcPacketKind Kind, uint MethodId, int InvokeId, byte[] Body);
|
||||
@@ -0,0 +1,34 @@
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace Ananta.SDK.Rpc;
|
||||
|
||||
public static class RpcPacketReader
|
||||
{
|
||||
public static RpcPacket Parse(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (payload.Length < 5)
|
||||
return new RpcPacket(RpcPacketKind.Unknown, 0, 0, payload.ToArray());
|
||||
|
||||
var tag = payload[0];
|
||||
var methodId = BinaryPrimitives.ReadUInt32LittleEndian(payload[1..5]);
|
||||
|
||||
if (tag == 0x03)
|
||||
{
|
||||
return new RpcPacket(RpcPacketKind.Notify, methodId, 0, payload[5..].ToArray());
|
||||
}
|
||||
|
||||
if (tag == 0x01 && payload.Length >= 9)
|
||||
{
|
||||
var invokeId = BinaryPrimitives.ReadInt32LittleEndian(payload[5..9]);
|
||||
return new RpcPacket(RpcPacketKind.Invoke, methodId, invokeId, payload[9..].ToArray());
|
||||
}
|
||||
|
||||
if (tag == 0x02 && payload.Length >= 13)
|
||||
{
|
||||
var invokeId = BinaryPrimitives.ReadInt32LittleEndian(payload[5..9]);
|
||||
return new RpcPacket(RpcPacketKind.Return, methodId, invokeId, payload[13..].ToArray());
|
||||
}
|
||||
|
||||
return new RpcPacket(RpcPacketKind.Unknown, methodId, 0, payload.ToArray());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace Ananta.SDK.Rpc;
|
||||
|
||||
public static class RpcPacketWriter
|
||||
{
|
||||
public static byte[] WriteNotify(uint methodId, ReadOnlySpan<byte> body)
|
||||
{
|
||||
var buffer = new byte[5 + body.Length];
|
||||
buffer[0] = 0x03;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(1, 4), methodId);
|
||||
body.CopyTo(buffer.AsSpan(5));
|
||||
return buffer;
|
||||
}
|
||||
|
||||
public static byte[] WriteReturn(uint methodId, int invokeId, int err, ReadOnlySpan<byte> body)
|
||||
{
|
||||
var buffer = new byte[13 + body.Length];
|
||||
buffer[0] = 0x02;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(1, 4), methodId);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(buffer.AsSpan(5, 4), invokeId);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(buffer.AsSpan(9, 4), err);
|
||||
body.CopyTo(buffer.AsSpan(13));
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using Ananta.SDK.Logging;
|
||||
using Ananta.SDK.Network;
|
||||
|
||||
namespace Ananta.SDK.Rpc;
|
||||
|
||||
public sealed class RpcRouter
|
||||
{
|
||||
private readonly Dictionary<uint, IRpcHandler> _invokeHandlers = new();
|
||||
private readonly Dictionary<uint, INotifyHandler> _notifyHandlers = new();
|
||||
private readonly ServerLogger _log;
|
||||
private readonly Dictionary<uint, string> _methodNames = new();
|
||||
private Func<RpcContext, Task>? _unknownInvokeHandler;
|
||||
private Func<RpcContext, Task>? _unknownNotifyHandler;
|
||||
|
||||
public RpcRouter(string scope) => _log = new ServerLogger($"router:{scope}");
|
||||
|
||||
public RpcRouter Name(uint methodId, string name)
|
||||
{
|
||||
// MethodId dump contains a few aliases with the same numeric id. Keep the first
|
||||
// canonical name instead of letting a later *_0 alias make logs harder to read.
|
||||
_methodNames.TryAdd(methodId, name);
|
||||
RpcMethodNames.Register(methodId, name);
|
||||
return this;
|
||||
}
|
||||
|
||||
private string Display(uint methodId)
|
||||
=> _methodNames.TryGetValue(methodId, out var name) ? $"{name} [0x{methodId:X8}]" : $"0x{methodId:X8}";
|
||||
|
||||
/// <summary>
|
||||
/// Build-specific compatibility fallback for invokes that are known to the client but do not
|
||||
/// yet have a semantic private-server implementation. The callback must return a body that
|
||||
/// matches the exact return reader for that client build; this avoids the old zero-byte/nil
|
||||
/// fallback which crashes strict generated Lua/C# callbacks.
|
||||
/// </summary>
|
||||
public RpcRouter OnUnknownInvoke(Func<RpcContext, Task> handler)
|
||||
{
|
||||
_unknownInvokeHandler = handler ?? throw new ArgumentNullException(nameof(handler));
|
||||
return this;
|
||||
}
|
||||
|
||||
public RpcRouter OnUnknownNotify(Func<RpcContext, Task> handler)
|
||||
{
|
||||
_unknownNotifyHandler = handler ?? throw new ArgumentNullException(nameof(handler));
|
||||
return this;
|
||||
}
|
||||
|
||||
public RpcRouter Add(IRpcHandler handler)
|
||||
{
|
||||
if (!_invokeHandlers.TryAdd(handler.MethodId, handler))
|
||||
throw new InvalidOperationException($"Duplicate invoke handler for {Display(handler.MethodId)}");
|
||||
return this;
|
||||
}
|
||||
|
||||
public RpcRouter Add(INotifyHandler handler)
|
||||
{
|
||||
if (!_notifyHandlers.TryAdd(handler.MethodId, handler))
|
||||
throw new InvalidOperationException($"Duplicate notify handler for {Display(handler.MethodId)}");
|
||||
return this;
|
||||
}
|
||||
|
||||
public async Task DispatchAsync(TcpSession session, Frame frame, CancellationToken token)
|
||||
{
|
||||
RpcPacket packet;
|
||||
try
|
||||
{
|
||||
packet = RpcPacketReader.Parse(frame.Payload);
|
||||
}
|
||||
catch
|
||||
{
|
||||
RuntimeLogs.RawFrame(session.Log.Scope, "C2S", frame.Mode, frame.Payload, "RPC_PARSE_FAILED");
|
||||
throw;
|
||||
}
|
||||
|
||||
var ctx = new RpcContext(session, packet, token);
|
||||
RuntimeLogs.Incoming(
|
||||
session.Log.Scope,
|
||||
packet.Kind == RpcPacketKind.Invoke ? RpcPacketKindView.Invoke :
|
||||
packet.Kind == RpcPacketKind.Notify ? RpcPacketKindView.Notify : RpcPacketKindView.Return,
|
||||
packet.MethodId, packet.InvokeId, packet.Body);
|
||||
|
||||
try
|
||||
{
|
||||
if (packet.Kind == RpcPacketKind.Invoke)
|
||||
{
|
||||
session.Log.Info($"-> rpc {Display(packet.MethodId)} #{packet.InvokeId} {packet.Body.Length}b");
|
||||
if (_invokeHandlers.TryGetValue(packet.MethodId, out var handler))
|
||||
{
|
||||
await handler.HandleAsync(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_unknownInvokeHandler is not null)
|
||||
{
|
||||
await _unknownInvokeHandler(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
session.Log.Warn($"unknown rpc {Display(packet.MethodId)} -> empty OK");
|
||||
await ctx.ReturnEmptyOkAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
if (packet.Kind == RpcPacketKind.Notify)
|
||||
{
|
||||
session.Log.Info($"-> ntf {Display(packet.MethodId)} {packet.Body.Length}b");
|
||||
if (_notifyHandlers.TryGetValue(packet.MethodId, out var handler))
|
||||
{
|
||||
await handler.HandleAsync(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_unknownNotifyHandler is not null)
|
||||
{
|
||||
await _unknownNotifyHandler(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
session.Log.Warn($"unknown ntf {Display(packet.MethodId)} ignored");
|
||||
return;
|
||||
}
|
||||
|
||||
session.Log.Warn($"Unknown rpc packet raw={Convert.ToHexString(frame.Payload)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
session.Log.Error($"handler failed method={Display(packet.MethodId)}", ex);
|
||||
if (packet.Kind == RpcPacketKind.Invoke)
|
||||
{
|
||||
if (_unknownInvokeHandler is not null)
|
||||
await _unknownInvokeHandler(ctx);
|
||||
else
|
||||
await ctx.ReturnEmptyOkAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using Ananta.SDK.Serialization;
|
||||
|
||||
using Ananta.SDK.Logging;
|
||||
|
||||
namespace Ananta.SDK.Rpc;
|
||||
|
||||
/// <summary>A parsed UX RPC message exposed to attribute handlers.</summary>
|
||||
public sealed class UxRpcMessage
|
||||
{
|
||||
public RpcContext Context { get; }
|
||||
public RpcPacketKind Kind => Context.Packet.Kind;
|
||||
public uint MethodId => Context.MethodId;
|
||||
public int InvokeId => Context.InvokeId;
|
||||
public byte[] Body => Context.Body;
|
||||
public bool IsInvoke => Kind == RpcPacketKind.Invoke;
|
||||
public bool IsNotify => Kind == RpcPacketKind.Notify;
|
||||
|
||||
internal UxRpcMessage(RpcContext context) => Context = context;
|
||||
|
||||
public T GetArgs<T>()
|
||||
{
|
||||
var value = UxSerializer.Deserialize<T>(Body);
|
||||
RuntimeLogs.Decoded(Context.Session.Log.Scope, "C2S", MethodId, typeof(T), value);
|
||||
return value;
|
||||
}
|
||||
|
||||
public bool TryGetArgs<T>(out T value)
|
||||
{
|
||||
try
|
||||
{
|
||||
value = GetArgs<T>();
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
value = default!;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Reflection;
|
||||
|
||||
namespace Ananta.SDK.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Marks a class/struct as a UX RPC contract. Inline contracts do not emit an object marker.
|
||||
/// TypeMark is used by the few polymorphic 4229938 contracts whose complex marker is a concrete
|
||||
/// type discriminator (for example WeaponData uses 1 instead of the normal 0xFF marker).
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)]
|
||||
public sealed class UxContractAttribute : Attribute
|
||||
{
|
||||
public bool Inline { get; set; }
|
||||
public byte TypeMark { get; set; } = 0xFF;
|
||||
}
|
||||
|
||||
public enum UxCountEncoding
|
||||
{
|
||||
Int32,
|
||||
Int7
|
||||
}
|
||||
|
||||
public enum UxObjectEncoding
|
||||
{
|
||||
Auto,
|
||||
Complex,
|
||||
Struct
|
||||
}
|
||||
|
||||
/// <summary>Overrides object framing for one field when the same client type is used both as a struct and complex.</summary>
|
||||
[AttributeUsage(AttributeTargets.Field)]
|
||||
public sealed class UxObjectAttribute : Attribute
|
||||
{
|
||||
public UxObjectEncoding Encoding { get; set; } = UxObjectEncoding.Auto;
|
||||
}
|
||||
|
||||
/// <summary>Overrides list/dictionary count encoding and nested object framing.</summary>
|
||||
[AttributeUsage(AttributeTargets.Field)]
|
||||
public sealed class UxCollectionAttribute : Attribute
|
||||
{
|
||||
public UxCountEncoding Count { get; set; } = UxCountEncoding.Int7;
|
||||
public UxObjectEncoding ItemObjectEncoding { get; set; } = UxObjectEncoding.Auto;
|
||||
public UxObjectEncoding ValueObjectEncoding { get; set; } = UxObjectEncoding.Auto;
|
||||
}
|
||||
|
||||
/// <summary>UX byte buffers are nullable complex values with either Int32 or biased 7-bit lengths.</summary>
|
||||
[AttributeUsage(AttributeTargets.Field)]
|
||||
public sealed class UxBufferAttribute : Attribute
|
||||
{
|
||||
public UxCountEncoding Count { get; set; } = UxCountEncoding.Int7;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reflection serializer for dump-shaped RPC DTOs. Public fields are serialized by declaration order,
|
||||
/// allowing RpcTypes to be read side-by-side with RPCSerializeAuto.lua / the IL2CPP dump.
|
||||
/// </summary>
|
||||
public static class UxSerializer
|
||||
{
|
||||
public static byte[] Serialize<T>(T value)
|
||||
{
|
||||
var writer = new UxWriter();
|
||||
WriteValue(writer, typeof(T), value, null, UxObjectEncoding.Auto);
|
||||
return writer.ToArray();
|
||||
}
|
||||
|
||||
public static T Deserialize<T>(ReadOnlySpan<byte> body)
|
||||
{
|
||||
var reader = new UxReader(body.ToArray());
|
||||
var value = ReadValue(reader, typeof(T), null, UxObjectEncoding.Auto);
|
||||
return value is null ? default! : (T)value;
|
||||
}
|
||||
|
||||
private static void WriteValue(
|
||||
UxWriter writer,
|
||||
Type type,
|
||||
object? value,
|
||||
FieldInfo? field,
|
||||
UxObjectEncoding objectEncoding)
|
||||
{
|
||||
if (type == typeof(byte)) { writer.U8(value is null ? (byte)0 : (byte)value); return; }
|
||||
if (type == typeof(sbyte)) { writer.U8(unchecked((byte)(value is null ? (sbyte)0 : (sbyte)value))); return; }
|
||||
if (type == typeof(bool)) { writer.Bool(value is not null && (bool)value); return; }
|
||||
if (type == typeof(short)) { writer.I16(value is null ? (short)0 : (short)value); return; }
|
||||
if (type == typeof(ushort)) { writer.U16(value is null ? (ushort)0 : (ushort)value); return; }
|
||||
if (type == typeof(int)) { writer.I32(value is null ? 0 : (int)value); return; }
|
||||
if (type == typeof(uint)) { writer.U32(value is null ? 0u : (uint)value); return; }
|
||||
if (type == typeof(long)) { writer.I64(value is null ? 0L : (long)value); return; }
|
||||
if (type == typeof(ulong)) { writer.U64(value is null ? 0UL : (ulong)value); return; }
|
||||
if (type == typeof(float)) { writer.F32(value is null ? 0f : (float)value); return; }
|
||||
if (type == typeof(double)) { writer.F64(value is null ? 0d : (double)value); return; }
|
||||
if (type == typeof(string)) { writer.UxString((string?)value); return; }
|
||||
|
||||
// byte[] is a UX buffer, not a normal List<byte>.
|
||||
if (type == typeof(byte[]))
|
||||
{
|
||||
WriteBuffer(writer, (byte[]?)value, field);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type.IsEnum)
|
||||
{
|
||||
var underlying = Enum.GetUnderlyingType(type);
|
||||
WriteValue(
|
||||
writer,
|
||||
underlying,
|
||||
value is null ? Activator.CreateInstance(underlying) : Convert.ChangeType(value, underlying),
|
||||
field,
|
||||
UxObjectEncoding.Auto);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type.IsArray)
|
||||
{
|
||||
var collection = (IList?)value;
|
||||
WriteList(writer, type.GetElementType()!, collection, field);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type.IsGenericType)
|
||||
{
|
||||
var generic = type.GetGenericTypeDefinition();
|
||||
if (generic == typeof(List<>))
|
||||
{
|
||||
WriteList(writer, type.GetGenericArguments()[0], (IList?)value, field);
|
||||
return;
|
||||
}
|
||||
|
||||
if (generic == typeof(Dictionary<,>))
|
||||
{
|
||||
WriteDictionary(writer, type, (IDictionary?)value, field);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var contract = type.GetCustomAttribute<UxContractAttribute>();
|
||||
if (contract is null)
|
||||
throw new NotSupportedException($"{type.FullName} is not a UX contract. Add [UxContract].");
|
||||
|
||||
var effectiveEncoding = objectEncoding;
|
||||
if (effectiveEncoding == UxObjectEncoding.Auto)
|
||||
effectiveEncoding = contract.Inline ? UxObjectEncoding.Struct : UxObjectEncoding.Complex;
|
||||
|
||||
if (effectiveEncoding == UxObjectEncoding.Complex)
|
||||
{
|
||||
if (value is null) { writer.U8(0x00); return; }
|
||||
writer.U8(contract.TypeMark);
|
||||
}
|
||||
else if (value is null)
|
||||
{
|
||||
// WriteStruct(nullable=false) falls back to a zero/default value in the real client.
|
||||
value = Activator.CreateInstance(type, nonPublic: true);
|
||||
}
|
||||
|
||||
foreach (var f in PublicFields(type))
|
||||
{
|
||||
var fieldEncoding = f.GetCustomAttribute<UxObjectAttribute>()?.Encoding ?? UxObjectEncoding.Auto;
|
||||
WriteValue(writer, f.FieldType, f.GetValue(value), f, fieldEncoding);
|
||||
}
|
||||
}
|
||||
|
||||
private static object? ReadValue(
|
||||
UxReader reader,
|
||||
Type type,
|
||||
FieldInfo? field,
|
||||
UxObjectEncoding objectEncoding)
|
||||
{
|
||||
if (type == typeof(byte)) return reader.U8();
|
||||
if (type == typeof(sbyte)) return unchecked((sbyte)reader.U8());
|
||||
if (type == typeof(bool)) return reader.Bool();
|
||||
if (type == typeof(short)) return reader.I16();
|
||||
if (type == typeof(ushort)) return reader.U16();
|
||||
if (type == typeof(int)) return reader.I32();
|
||||
if (type == typeof(uint)) return reader.U32();
|
||||
if (type == typeof(long)) return reader.I64();
|
||||
if (type == typeof(ulong)) return reader.U64();
|
||||
if (type == typeof(float)) return reader.F32();
|
||||
if (type == typeof(double)) return reader.F64();
|
||||
if (type == typeof(string)) return reader.UxStringNullable();
|
||||
if (type == typeof(byte[])) return ReadBuffer(reader, field);
|
||||
|
||||
if (type.IsEnum)
|
||||
return Enum.ToObject(type, ReadValue(reader, Enum.GetUnderlyingType(type), field, UxObjectEncoding.Auto)!);
|
||||
|
||||
if (type.IsArray)
|
||||
{
|
||||
var list = ReadList(reader, type.GetElementType()!, field);
|
||||
if (list is null) return null;
|
||||
var array = Array.CreateInstance(type.GetElementType()!, list.Count);
|
||||
list.CopyTo(array, 0);
|
||||
return array;
|
||||
}
|
||||
|
||||
if (type.IsGenericType)
|
||||
{
|
||||
var generic = type.GetGenericTypeDefinition();
|
||||
if (generic == typeof(List<>)) return ReadList(reader, type.GetGenericArguments()[0], field, type);
|
||||
if (generic == typeof(Dictionary<,>)) return ReadDictionary(reader, type, field);
|
||||
}
|
||||
|
||||
var contract = type.GetCustomAttribute<UxContractAttribute>();
|
||||
if (contract is null)
|
||||
throw new NotSupportedException($"{type.FullName} is not a UX contract. Add [UxContract].");
|
||||
|
||||
var effectiveEncoding = objectEncoding;
|
||||
if (effectiveEncoding == UxObjectEncoding.Auto)
|
||||
effectiveEncoding = contract.Inline ? UxObjectEncoding.Struct : UxObjectEncoding.Complex;
|
||||
|
||||
if (effectiveEncoding == UxObjectEncoding.Complex && reader.U8() == 0x00)
|
||||
return null;
|
||||
|
||||
var obj = Activator.CreateInstance(type, nonPublic: true)
|
||||
?? throw new InvalidOperationException($"Cannot create {type.FullName}");
|
||||
foreach (var f in PublicFields(type))
|
||||
{
|
||||
var fieldEncoding = f.GetCustomAttribute<UxObjectAttribute>()?.Encoding ?? UxObjectEncoding.Auto;
|
||||
f.SetValue(obj, ReadValue(reader, f.FieldType, f, fieldEncoding));
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
private static void WriteBuffer(UxWriter writer, byte[]? data, FieldInfo? field)
|
||||
{
|
||||
if (data is null) { writer.U8(0x00); return; }
|
||||
writer.U8(0xFF);
|
||||
var encoding = field?.GetCustomAttribute<UxBufferAttribute>()?.Count ?? UxCountEncoding.Int7;
|
||||
if (encoding == UxCountEncoding.Int7) writer.Int7(data.Length); else writer.I32(data.Length);
|
||||
writer.Raw(data);
|
||||
}
|
||||
|
||||
private static byte[]? ReadBuffer(UxReader reader, FieldInfo? field)
|
||||
{
|
||||
if (reader.U8() == 0x00) return null;
|
||||
var encoding = field?.GetCustomAttribute<UxBufferAttribute>()?.Count ?? UxCountEncoding.Int7;
|
||||
var count = encoding == UxCountEncoding.Int7 ? reader.Int7() : reader.I32();
|
||||
return reader.Bytes(count);
|
||||
}
|
||||
|
||||
private static void WriteList(UxWriter writer, Type itemType, IList? list, FieldInfo? field)
|
||||
{
|
||||
if (list is null) { writer.U8(0x00); return; }
|
||||
writer.U8(0xFF);
|
||||
WriteCount(writer, list.Count, field);
|
||||
var itemEncoding = field?.GetCustomAttribute<UxCollectionAttribute>()?.ItemObjectEncoding ?? UxObjectEncoding.Auto;
|
||||
foreach (var item in list)
|
||||
WriteValue(writer, itemType, item, null, itemEncoding);
|
||||
}
|
||||
|
||||
private static IList? ReadList(UxReader reader, Type itemType, FieldInfo? field, Type? concreteType = null)
|
||||
{
|
||||
if (reader.U8() == 0x00) return null;
|
||||
var count = ReadCount(reader, field);
|
||||
var listType = concreteType ?? typeof(List<>).MakeGenericType(itemType);
|
||||
var list = (IList)(Activator.CreateInstance(listType) ?? throw new InvalidOperationException($"Cannot create {listType}"));
|
||||
var itemEncoding = field?.GetCustomAttribute<UxCollectionAttribute>()?.ItemObjectEncoding ?? UxObjectEncoding.Auto;
|
||||
for (var i = 0; i < count; i++) list.Add(ReadValue(reader, itemType, null, itemEncoding));
|
||||
return list;
|
||||
}
|
||||
|
||||
private static void WriteDictionary(UxWriter writer, Type dictType, IDictionary? dict, FieldInfo? field)
|
||||
{
|
||||
if (dict is null) { writer.U8(0x00); return; }
|
||||
writer.U8(0xFF);
|
||||
WriteCount(writer, dict.Count, field, defaultEncoding: UxCountEncoding.Int32);
|
||||
var args = dictType.GetGenericArguments();
|
||||
var valueEncoding = field?.GetCustomAttribute<UxCollectionAttribute>()?.ValueObjectEncoding ?? UxObjectEncoding.Auto;
|
||||
foreach (DictionaryEntry item in dict)
|
||||
{
|
||||
WriteValue(writer, args[0], item.Key, null, UxObjectEncoding.Auto);
|
||||
WriteValue(writer, args[1], item.Value, null, valueEncoding);
|
||||
}
|
||||
}
|
||||
|
||||
private static object? ReadDictionary(UxReader reader, Type dictType, FieldInfo? field)
|
||||
{
|
||||
if (reader.U8() == 0x00) return null;
|
||||
var count = ReadCount(reader, field, defaultEncoding: UxCountEncoding.Int32);
|
||||
var dict = (IDictionary)(Activator.CreateInstance(dictType) ?? throw new InvalidOperationException($"Cannot create {dictType}"));
|
||||
var args = dictType.GetGenericArguments();
|
||||
var valueEncoding = field?.GetCustomAttribute<UxCollectionAttribute>()?.ValueObjectEncoding ?? UxObjectEncoding.Auto;
|
||||
for (var i = 0; i < count; i++)
|
||||
dict.Add(
|
||||
ReadValue(reader, args[0], null, UxObjectEncoding.Auto)!,
|
||||
ReadValue(reader, args[1], null, valueEncoding));
|
||||
return dict;
|
||||
}
|
||||
|
||||
private static void WriteCount(UxWriter writer, int count, FieldInfo? field, UxCountEncoding defaultEncoding = UxCountEncoding.Int7)
|
||||
{
|
||||
var encoding = field?.GetCustomAttribute<UxCollectionAttribute>()?.Count ?? defaultEncoding;
|
||||
if (encoding == UxCountEncoding.Int7) writer.Int7(count); else writer.I32(count);
|
||||
}
|
||||
|
||||
private static int ReadCount(UxReader reader, FieldInfo? field, UxCountEncoding defaultEncoding = UxCountEncoding.Int7)
|
||||
{
|
||||
var encoding = field?.GetCustomAttribute<UxCollectionAttribute>()?.Count ?? defaultEncoding;
|
||||
return encoding == UxCountEncoding.Int7 ? reader.Int7() : reader.I32();
|
||||
}
|
||||
|
||||
private static readonly ConcurrentDictionary<Type, FieldInfo[]> FieldCache = new();
|
||||
|
||||
private static FieldInfo[] PublicFields(Type type)
|
||||
=> FieldCache.GetOrAdd(type, static t =>
|
||||
t.GetFields(BindingFlags.Instance | BindingFlags.Public)
|
||||
.OrderBy(f => f.MetadataToken)
|
||||
.ToArray());
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace Ananta.SDK.Serialization;
|
||||
|
||||
public sealed class UxReader
|
||||
{
|
||||
private readonly byte[] _data;
|
||||
private int _pos;
|
||||
|
||||
public UxReader(byte[] data) => _data = data;
|
||||
public int Position => _pos;
|
||||
public int Remaining => _data.Length - _pos;
|
||||
|
||||
public byte U8() => _data[_pos++];
|
||||
public bool Bool() => U8() != 0;
|
||||
|
||||
public short I16()
|
||||
{
|
||||
var v = BinaryPrimitives.ReadInt16LittleEndian(_data.AsSpan(_pos, 2));
|
||||
_pos += 2;
|
||||
return v;
|
||||
}
|
||||
|
||||
public ushort U16()
|
||||
{
|
||||
var v = BinaryPrimitives.ReadUInt16LittleEndian(_data.AsSpan(_pos, 2));
|
||||
_pos += 2;
|
||||
return v;
|
||||
}
|
||||
|
||||
public int I32()
|
||||
{
|
||||
var v = BinaryPrimitives.ReadInt32LittleEndian(_data.AsSpan(_pos, 4));
|
||||
_pos += 4;
|
||||
return v;
|
||||
}
|
||||
|
||||
public uint U32()
|
||||
{
|
||||
var v = BinaryPrimitives.ReadUInt32LittleEndian(_data.AsSpan(_pos, 4));
|
||||
_pos += 4;
|
||||
return v;
|
||||
}
|
||||
|
||||
public long I64()
|
||||
{
|
||||
var v = BinaryPrimitives.ReadInt64LittleEndian(_data.AsSpan(_pos, 8));
|
||||
_pos += 8;
|
||||
return v;
|
||||
}
|
||||
|
||||
public ulong U64()
|
||||
{
|
||||
var v = BinaryPrimitives.ReadUInt64LittleEndian(_data.AsSpan(_pos, 8));
|
||||
_pos += 8;
|
||||
return v;
|
||||
}
|
||||
|
||||
public float F32()
|
||||
{
|
||||
var v = BitConverter.ToSingle(_data, _pos);
|
||||
_pos += 4;
|
||||
return v;
|
||||
}
|
||||
|
||||
public double F64()
|
||||
{
|
||||
var v = BitConverter.ToDouble(_data, _pos);
|
||||
_pos += 8;
|
||||
return v;
|
||||
}
|
||||
|
||||
public int Int7() => Math.Max(0, ReadVarInt() - 1);
|
||||
|
||||
public string UxString() => UxStringNullable() ?? string.Empty;
|
||||
|
||||
public string? UxStringNullable()
|
||||
{
|
||||
var lenPlus = ReadVarInt();
|
||||
if (lenPlus == 0) return null;
|
||||
var len = Math.Max(0, lenPlus - 1);
|
||||
if (len > Remaining) throw new InvalidDataException($"UX string length {len} exceeds remaining {Remaining} bytes.");
|
||||
var s = Encoding.UTF8.GetString(_data, _pos, len);
|
||||
_pos += len;
|
||||
return s;
|
||||
}
|
||||
|
||||
public byte[] Bytes(int count)
|
||||
{
|
||||
if (count < 0 || count > Remaining)
|
||||
throw new InvalidDataException($"UX buffer length {count} exceeds remaining {Remaining} bytes.");
|
||||
var result = _data.AsSpan(_pos, count).ToArray();
|
||||
_pos += count;
|
||||
return result;
|
||||
}
|
||||
|
||||
private int ReadVarInt()
|
||||
{
|
||||
var shift = 0;
|
||||
var result = 0;
|
||||
while (_pos < _data.Length)
|
||||
{
|
||||
var b = _data[_pos++];
|
||||
result |= (b & 0x7F) << shift;
|
||||
if ((b & 0x80) == 0) break;
|
||||
shift += 7;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace Ananta.SDK.Serialization;
|
||||
|
||||
public sealed class UxWriter
|
||||
{
|
||||
private readonly MemoryStream _ms = new();
|
||||
|
||||
public UxWriter U8(byte value) { _ms.WriteByte(value); return this; }
|
||||
public UxWriter Bool(bool value) { _ms.WriteByte(value ? (byte)1 : (byte)0); return this; }
|
||||
public UxWriter I16(short value) { Span<byte> b = stackalloc byte[2]; BinaryPrimitives.WriteInt16LittleEndian(b, value); _ms.Write(b); return this; }
|
||||
public UxWriter U16(ushort value) { Span<byte> b = stackalloc byte[2]; BinaryPrimitives.WriteUInt16LittleEndian(b, value); _ms.Write(b); return this; }
|
||||
public UxWriter I32(int value) { Span<byte> b = stackalloc byte[4]; BinaryPrimitives.WriteInt32LittleEndian(b, value); _ms.Write(b); return this; }
|
||||
public UxWriter U32(uint value) { Span<byte> b = stackalloc byte[4]; BinaryPrimitives.WriteUInt32LittleEndian(b, value); _ms.Write(b); return this; }
|
||||
public UxWriter I64(long value) { Span<byte> b = stackalloc byte[8]; BinaryPrimitives.WriteInt64LittleEndian(b, value); _ms.Write(b); return this; }
|
||||
public UxWriter U64(ulong value) { Span<byte> b = stackalloc byte[8]; BinaryPrimitives.WriteUInt64LittleEndian(b, value); _ms.Write(b); return this; }
|
||||
public UxWriter F32(float value) { var b = BitConverter.GetBytes(value); _ms.Write(b); return this; }
|
||||
public UxWriter F64(double value) { var b = BitConverter.GetBytes(value); _ms.Write(b); return this; }
|
||||
|
||||
public UxWriter UxString(string? value)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
_ms.WriteByte(0x00);
|
||||
return this;
|
||||
}
|
||||
|
||||
var utf8 = Encoding.UTF8.GetBytes(value);
|
||||
WriteVarInt(utf8.Length + 1);
|
||||
_ms.Write(utf8);
|
||||
return this;
|
||||
}
|
||||
|
||||
public UxWriter Raw(ReadOnlySpan<byte> bytes) { _ms.Write(bytes); return this; }
|
||||
|
||||
// Matches client UXBinaryWriter.Write7BitEncodedInt exactly: the client
|
||||
// increments the logical value before emitting the unsigned 7-bit varint.
|
||||
// Its reader decodes the varint and subtracts one.
|
||||
public UxWriter Int7(int value)
|
||||
{
|
||||
if (value < 0) throw new ArgumentOutOfRangeException(nameof(value));
|
||||
WriteVarInt(checked(value + 1));
|
||||
return this;
|
||||
}
|
||||
|
||||
public byte[] ToArray() => _ms.ToArray();
|
||||
|
||||
private void WriteVarInt(int value)
|
||||
{
|
||||
uint v = (uint)value;
|
||||
while (v >= 0x80)
|
||||
{
|
||||
_ms.WriteByte((byte)(v | 0x80));
|
||||
v >>= 7;
|
||||
}
|
||||
_ms.WriteByte((byte)v);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user