Initial commit: Ananta CBT 4229938 private server with all fixes

This commit is contained in:
CAtlas0784 committed 2026-09-15 18:13:46 +07:00
commit c84135c357
286 files changed
+2214315

No files matched your search

+8
View File
@@ -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}";
}
+312
View File
@@ -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}";
}
}
+78
View File
@@ -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,
};
}
}
+3
View File
@@ -0,0 +1,3 @@
namespace Ananta.SDK.Network;
public readonly record struct Frame(byte Mode, byte[] Payload);
+77
View File
@@ -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;
}
}
+76
View File
@@ -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();
}
}
}
+125
View File
@@ -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();
}
}
+109
View File
@@ -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);
}
}
+57
View File
@@ -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);
}
+25
View File
@@ -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;
}
}
+7
View File
@@ -0,0 +1,7 @@
namespace Ananta.SDK.Rpc;
public interface INotifyHandler
{
uint MethodId { get; }
Task HandleAsync(RpcContext ctx);
}
+7
View File
@@ -0,0 +1,7 @@
namespace Ananta.SDK.Rpc;
public interface IRpcHandler
{
uint MethodId { get; }
Task HandleAsync(RpcContext ctx);
}
+48
View File
@@ -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;
}
}
+11
View File
@@ -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);
+34
View File
@@ -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());
}
}
+26
View File
@@ -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;
}
}
+136
View File
@@ -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();
}
}
}
}
+40
View File
@@ -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;
}
}
}
+308
View File
@@ -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());
}
+111
View File
@@ -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;
}
}
+59
View File
@@ -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);
}
}