diff --git a/CHANGELOG.md b/CHANGELOG.md index c6b5834..bee6323 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.2.0] - 2026-01-11 + +### Added +- **Ghost Reader**: A zero-copy, zero-allocation `ref struct` view over binary data for ultra-low latency access. +- **Convenience Methods**: `ComputeSize()` and `ToBytes()` extension methods for generated Ghost types. +- **Documentation**: New `docs/GHOST_READER.md` dedicated guide. +- **Reliability**: Robust handling for nullable strings and buffer overflows in `RappWriter`. + ## [1.1.0] - 2026-01-07 ### Added diff --git a/README.md b/README.md index 83ca182..1f0dfc8 100644 --- a/README.md +++ b/README.md @@ -252,6 +252,22 @@ Rapp is **100% compatible with Native AOT** (Ahead-of-Time) compilation, making ## 🎯 Advanced Features +### 👻 The Ghost Reader (Zero-Copy View) + +Rapp includes a "Ghost Reader" feature for ultra-low latency scenarios (trading, gaming, IoT). Instead of deserializing objects on the heap, it creates a **Zero-Copy, Zero-Allocation** `ref struct` view over your raw binary data. + +```csharp +// 1. Define +[RappGhost] +public class Tick { public double Price; public long Time; } + +// 2. Read (Zero Allocation) +var ghost = new TickGhost(buffer); +if (ghost.Price > 100.0) { ... } // instant access +``` + +[👉 Read the full Ghost Reader Documentation](docs/GHOST_READER.md) + ### Integration with Existing Code Rapp works with your existing `HybridCache` setup - no code changes required: diff --git a/docs/GHOST_READER.md b/docs/GHOST_READER.md new file mode 100644 index 0000000..9cde51c --- /dev/null +++ b/docs/GHOST_READER.md @@ -0,0 +1,100 @@ +# The Rapp Ghost Reader 👻 + +The **Ghost Reader** is a high-performance, Zero-Copy view over your raw data. It allows you to access properties of your objects directly from a binary buffer without deserializing the entire object or allocating a single byte of memory on the heap. + +This pattern is ideal for high-throughput scenarios (gaming, trading, IoT) where garbage collection (GC) pauses are unacceptable. + +## 🚀 Key Benefits + +| Feature | Traditional (JSON/Protobuf) | Rapp Ghost Reader | +| :--- | :--- | :--- | +| **Allocation** | Creates new objects on Heap | **Zero Allocation** (Stack only) | +| **Parsing** | Parses full object on read | **Lazy / On-Demand** (O(1) access) | +| **GC Pressure** | High (creates garbage) | **None** (Invisible to GC) | +| **Speed** | Slow (Reflection + Allocations) | **Instant** (Pointer arithmetic) | + +## 📦 How to Use + +### 1. Opt-in with `[RappGhost]` + +Mark your class or struct with the attribute. Rapp supports primitives (`int`, `bool`, `double`, etc.) and `string` (variable length). + +```csharp +using Rapp; + +[RappGhost] +public class Player +{ + public int Id { get; set; } + public string Name { get; set; } + public bool IsActive { get; set; } + public double Health { get; set; } +} +``` + +### 2. Write Data (Serialization) + +Use the generated `.WriteTo()` extension method to serialize your object to a span of bytes. + +```csharp +var player = new Player { Id = 1, Name = "Viking", IsActive = true, Health = 100.0 }; +Span buffer = stackalloc byte[128]; // Stack allocate for speed! + +int bytesWritten = player.WriteTo(buffer); +``` + +### 3. Read Data (The Ghost) + +Wrap the buffer with the generated `*Ghost` struct. This looks and feels like your object, but it's just a view over the bytes. + +```csharp +// Create the view (Zero allocation) +var ghost = new PlayerGhost(buffer.Slice(0, bytesWritten)); + +// READ properties instantly +if (ghost.IsActive) // Reads byte at offset X +{ + Console.WriteLine($"Player {ghost.Id} has {ghost.Health} health."); +} + +// READ Strings +// Returns ReadOnlySpan to avoid allocation +ReadOnlySpan nameBytes = ghost.Name; + +// Or convert to string if you really need it (allocates) +Console.WriteLine(ghost.NameString); +``` + +### 🎁 Convenience Methods + +If you prefer ease of use over raw stack allocation, Rapp generates helper methods for you: + +```csharp +var player = new Player { Name = "Convenient" }; + +// 1. Compute Exact Size +int size = player.ComputeSize(); + +// 2. Direct to Byte Array (Allocates, but easier) +byte[] data = player.ToBytes(); +``` + +## 🧠 How It Works (The "Head/Tail" Layout) + +Rapp uses a split binary layout to guarantee O(1) random access for fixed fields while supporting variable-length data (strings). + +```text +[ ID (4b) ] [ Health (8b) ] [ Name Pointer (2b) ] ... [ Name Length (2b) ] [ "Viking" (bytes) ] +<---------------- HEAD (Fixed) -----------------> <------------ TAIL (Variable) ----------> +``` + +1. **Head Region**: Contains all fixed-size data (`int`, `double`, `bool`) and *pointers* (offsets) to variable data. + * This allows us to know exactly where `Id` is (Offset 0) without parsing anything else. +2. **Tail Region**: Contains the variable-length data (strings). + * The "Pointer" in the Head tells the Ghost Reader exactly where to jump in the generic buffer to find the string. + +## ⚠️ Constraints & Best Practices + +1. **Max String Size**: The current implementation uses `ushort` for pointers and lengths, limiting individual string lengths and the total buffer size to **64KB**. This is optimized for network packets (UDP/TCP). +2. **Buffer Safety**: The Ghost Reader assumes the underlying buffer is valid for the lifetime of the struct. Since it is a `ref struct`, the compiler enforces that it cannot escape the stack frame of the buffer, providing memory safety. +3. **Schema Evolution**: The Ghost Reader generates code based on *current* class definitions. If you change the class, you must recompile. It does not currently support backward/forward compatibility (schema versioning) like the main Rapp serializer. diff --git a/src/Rapp.Gen/RappGhostGenerator.cs b/src/Rapp.Gen/RappGhostGenerator.cs new file mode 100644 index 0000000..3709963 --- /dev/null +++ b/src/Rapp.Gen/RappGhostGenerator.cs @@ -0,0 +1,257 @@ +using System; +using System.Text; +using System.Linq; +using System.Collections.Generic; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace Rapp.Gen +{ + [Generator] + public class RappGhostGenerator : IIncrementalGenerator + { + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var p = context.SyntaxProvider.CreateSyntaxProvider( + predicate: (s, _) => IsTarget(s), + transform: (ctx, _) => GetTarget(ctx)) + .Where(m => m != null); + + context.RegisterSourceOutput(p, Execute); + } + + private static bool IsTarget(SyntaxNode node) + { + return node is ClassDeclarationSyntax c && c.AttributeLists.Count > 0; + } + + private static ClassDeclarationSyntax? GetTarget(GeneratorSyntaxContext context) + { + var classDeclaration = (ClassDeclarationSyntax)context.Node; + + foreach (var attributeList in classDeclaration.AttributeLists) + { + foreach (var attribute in attributeList.Attributes) + { + if (context.SemanticModel.GetSymbolInfo(attribute).Symbol is IMethodSymbol attributeSymbol && + attributeSymbol.ContainingType.ToDisplayString() == "Rapp.RappGhostAttribute") + { + return classDeclaration; + } + } + } + return null; + } + + private static void Execute(SourceProductionContext context, ClassDeclarationSyntax? classDecl) + { + if (classDecl is null) return; + + var className = classDecl.Identifier.Text; + var ghostName = $"{className}Ghost"; + var sb = new StringBuilder(); + sb.AppendLine("// "); + + string ns = GetNamespace(classDecl); + + sb.AppendLine("using System;"); + sb.AppendLine("using System.Buffers.Binary;"); + sb.AppendLine("using System.Runtime.InteropServices;"); + sb.AppendLine("using Rapp;"); // For RappWriter + sb.AppendLine(); + sb.AppendLine($"namespace {ns}"); + sb.AppendLine("{"); + + // --- GHOST STRUCT --- + sb.AppendLine($" public ref struct {ghostName}"); + sb.AppendLine(" {"); + sb.AppendLine(" private readonly ReadOnlySpan _buffer;"); + sb.AppendLine(); + sb.AppendLine($" public {ghostName}(ReadOnlySpan buffer)"); + sb.AppendLine(" {"); + sb.AppendLine(" _buffer = buffer;"); + sb.AppendLine(" }"); + sb.AppendLine(); + + int currentOffset = 0; + var properties = new List<(string Name, string Type, int Offset, bool IsVar)>(); + + foreach (var member in classDecl.Members) + { + if (member is PropertyDeclarationSyntax prop) + { + string propName = prop.Identifier.Text; + string propType = prop.Type.ToString(); // "string", "string?", "int", etc. + + // Normalize type for lookup + string normalizedType = propType.TrimEnd('?'); + + int size = GetSize(normalizedType); + bool isString = normalizedType == "string"; + + properties.Add((propName, propType, currentOffset, isString)); + + // Increment offset for HEAD calculation + currentOffset += size; + } + } + + // Generate Getters + foreach (var p in properties) + { + string normalizedType = p.Type.TrimEnd('?'); + string? getter = GenerateGetter(normalizedType, p.Offset); + + if (p.IsVar) // string or string? -> ReadOnlySpan + { + // Special handling for string property in Ghost + sb.AppendLine($" public ReadOnlySpan {p.Name}"); + sb.AppendLine(getter); + + // Helper for string + sb.AppendLine($" public string {p.Name}String => System.Text.Encoding.UTF8.GetString({p.Name});"); + } + else if (getter != null) + { + sb.AppendLine($" public {normalizedType} {p.Name} => {getter};"); + } + } + + sb.AppendLine(" }"); // End struct + + // --- WRITER EXTENSIONS --- + sb.AppendLine(); + sb.AppendLine($" public static class {className}RappExtensions"); + sb.AppendLine(" {"); + sb.AppendLine($" public static int WriteTo(this {className} value, Span buffer)"); + sb.AppendLine(" {"); + sb.AppendLine($" var writer = new RappWriter(buffer, headSize: {currentOffset});"); + + foreach (var p in properties) + { + string normalizedType = p.Type.TrimEnd('?'); + GenerateWriteCall(sb, p.Name, normalizedType); + } + + sb.AppendLine(" return writer.Length;"); + sb.AppendLine(" }"); + sb.AppendLine(); + + // --- ComputeSize --- + sb.AppendLine($" public static int ComputeSize(this {className} value)"); + sb.AppendLine(" {"); + sb.AppendLine($" int size = {currentOffset}; // Head Size"); + foreach (var p in properties) + { + // Normalized type is needed for logic check, though GetByteCount handles string? + if (p.IsVar) + { + sb.AppendLine($" if (value.{p.Name} != null)"); + sb.AppendLine($" {{"); + sb.AppendLine($" size += 2 + System.Text.Encoding.UTF8.GetByteCount(value.{p.Name});"); + sb.AppendLine($" }}"); + } + } + sb.AppendLine(" return size;"); + sb.AppendLine(" }"); + sb.AppendLine(); + + // --- ToBytes --- + sb.AppendLine($" public static byte[] ToBytes(this {className} value)"); + sb.AppendLine(" {"); + sb.AppendLine(" int size = value.ComputeSize();"); + sb.AppendLine(" byte[] buffer = new byte[size];"); + sb.AppendLine(" value.WriteTo(buffer);"); + sb.AppendLine(" return buffer;"); + sb.AppendLine(" }"); + + sb.AppendLine(" }"); + + sb.AppendLine("}"); // End namespace + + context.AddSource($"{ghostName}.g.cs", SourceText.From(sb.ToString(), Encoding.UTF8)); + } + + private static int GetSize(string type) + { + return type switch + { + "int" => 4, + "uint" => 4, + "long" => 8, + "ulong" => 8, + "short" => 2, + "ushort" => 2, + "byte" => 1, + "bool" => 1, + "double" => 8, + "float" => 4, + "string" => 2, // Pointer size (ushort) + _ => 0, + }; + } + + private static string? GenerateGetter(string type, int offset) + { + if (type == "string") + { + return $@" + {{ + get + {{ + ushort ptr = BinaryPrimitives.ReadUInt16LittleEndian(_buffer.Slice({offset})); + if (ptr == 0) return ReadOnlySpan.Empty; + var tail = _buffer.Slice(ptr); + ushort length = BinaryPrimitives.ReadUInt16LittleEndian(tail); + return tail.Slice(2, length); + }} + }}"; + } + // ... (rest of implementation) + + return type switch + { + "int" => $"BinaryPrimitives.ReadInt32LittleEndian(_buffer.Slice({offset}))", + "uint" => $"BinaryPrimitives.ReadUInt32LittleEndian(_buffer.Slice({offset}))", + "long" => $"BinaryPrimitives.ReadInt64LittleEndian(_buffer.Slice({offset}))", + "ulong" => $"BinaryPrimitives.ReadUInt64LittleEndian(_buffer.Slice({offset}))", + "short" => $"BinaryPrimitives.ReadInt16LittleEndian(_buffer.Slice({offset}))", + "ushort" => $"BinaryPrimitives.ReadUInt16LittleEndian(_buffer.Slice({offset}))", + "bool" => $"_buffer[{offset}] != 0", + "byte" => $"_buffer[{offset}]", + "double" => $"BitConverter.Int64BitsToDouble(BinaryPrimitives.ReadInt64LittleEndian(_buffer.Slice({offset})))", + _ => null + }; + } + + private static void GenerateWriteCall(StringBuilder sb, string name, string type) + { + string call = type switch + { + "int" => $"writer.WriteInt32(value.{name});", + "long" => $"writer.WriteInt64(value.{name});", + "short" => $"writer.WriteInt16(value.{name});", + "byte" => $"writer.WriteByte(value.{name});", + "bool" => $"writer.WriteBool(value.{name});", + "double" => $"writer.WriteDouble(value.{name});", + "float" => $"writer.WriteSingle(value.{name});", + "string" => $"writer.WriteString(value.{name});", + _ => $"// Unsupported type: {type}" + }; + sb.AppendLine($" {call}"); + } + + private static string GetNamespace(SyntaxNode node) + { + var curr = node.Parent; + while (curr != null) + { + if (curr is NamespaceDeclarationSyntax ns) return ns.Name.ToString(); + if (curr is FileScopedNamespaceDeclarationSyntax fns) return fns.Name.ToString(); + curr = curr.Parent; + } + return "Rapp.Generated"; + } + } +} diff --git a/src/Rapp.Tests/GhostTests.cs b/src/Rapp.Tests/GhostTests.cs new file mode 100644 index 0000000..e56af31 --- /dev/null +++ b/src/Rapp.Tests/GhostTests.cs @@ -0,0 +1,244 @@ +using System; +using System.Text; +using FluentAssertions; +using Xunit; +using Rapp; + +namespace Rapp.Tests +{ + [RappGhost] + public class User + { + public int Id { get; set; } + public string? Name { get; set; } + public bool IsActive { get; set; } + public double Score { get; set; } + } + + [RappGhost] + public class Complex + { + public int Id { get; set; } + public string? FirstName { get; set; } + public string? LastName { get; set; } + public string? Description { get; set; } // For unicode + public long Timestamp { get; set; } + } + + public class GhostTests + { + [Fact] + public void GhostReader_MultipleStrings_CorrectOffsets() + { + var data = new Complex + { + Id = 1, + FirstName = "Alice", + LastName = "Wonderland", + Timestamp = 123456789 + }; + + Span buffer = stackalloc byte[512]; + int written = data.WriteTo(buffer); + + var ghost = new ComplexGhost(buffer.Slice(0, written)); + + ghost.Id.Should().Be(1); + ghost.FirstNameString.Should().Be("Alice"); + ghost.LastNameString.Should().Be("Wonderland"); + ghost.Timestamp.Should().Be(123456789); + + // Description was null, should be empty + ghost.Description.IsEmpty.Should().BeTrue(); + } + + [Fact] + public void GhostReader_UnicodeAndEmojis_HandlesByteLengthCorrectly() + { + // "🚀" is 4 bytes in UTF-8, but 2 chars in C# (surrogate pair) + // "Rapp" is 4 bytes + var text = "Rapp 🚀 Ghost"; + + var data = new Complex + { + Description = text + }; + + Span buffer = stackalloc byte[512]; + int written = data.WriteTo(buffer); + + var ghost = new ComplexGhost(buffer.Slice(0, written)); + + // Verify full string roundtrip + ghost.DescriptionString.Should().Be(text); + + // Verify raw byte length (Rapp=4 + space=1 + Rocket=4 + space=1 + Ghost=5 = 15 bytes) + ghost.Description.Length.Should().Be(Encoding.UTF8.GetByteCount(text)); + } + + [Fact] + public void GhostReader_EmptyStrings_ZeroLength() + { + var data = new Complex { FirstName = "", LastName = "" }; + Span buffer = stackalloc byte[256]; + int written = data.WriteTo(buffer); + + var ghost = new ComplexGhost(buffer.Slice(0, written)); + + ghost.FirstNameString.Should().BeEmpty(); + ghost.LastNameString.Should().BeEmpty(); + ghost.FirstName.Length.Should().Be(0); + } + + [Fact] + public void GhostReader_ReadWrite_Success() + { + var user = new User + { + Id = 12345, + Name = "RappGhost", + IsActive = true, + Score = 99.9 + }; + + Span buffer = stackalloc byte[256]; + int written = user.WriteTo(buffer); + + var ghost = new UserGhost(buffer.Slice(0, written)); + + ghost.Id.Should().Be(12345); + ghost.IsActive.Should().BeTrue(); + ghost.Score.Should().Be(99.9); + ghost.NameString.Should().Be("RappGhost"); + } + + [Fact] + public void GhostReader_VariableLength_Check() + { + var user = new User { Id = 1, Name = "A very long string for testing", IsActive = false, Score = 1.0 }; + Span buffer = new byte[1024]; + int written = user.WriteTo(buffer); + + var ghost = new UserGhost(buffer.Slice(0, written)); + ghost.NameString.Should().Be("A very long string for testing"); + } + + [Fact] + public void GhostReader_NullString_Check() + { + var user = new User { Id = 2, Name = null, IsActive = true, Score = 0.0 }; + Span buffer = new byte[128]; + int written = user.WriteTo(buffer); + + var ghost = new UserGhost(buffer.Slice(0, written)); + + ghost.Name.IsEmpty.Should().BeTrue(); + ghost.NameString.Should().BeEmpty(); + } + + [Fact] + public void GhostReader_ConvenienceMethods_Work() + { + var user = new User { Id = 5, Name = "Convenience", IsActive = true, Score = 5.5 }; + + // 1. Easy Mode: ToBytes() + byte[] bytes = user.ToBytes(); // codeql[cs/empty-collection] - False positive, ToBytes returns populated array + + // 2. Verify Size + int expectedSize = + 4 + // Id + 2 + // Name Ptr + 1 + // IsActive + 8 + // Score + 2 + // Name Length + 11; // "Convenience" bytes + + bytes.Length.Should().Be(expectedSize); + user.ComputeSize().Should().Be(expectedSize); + + // 3. Read it back + var ghost = new UserGhost(bytes); + ghost.NameString.Should().Be("Convenience"); + } + + [Fact] + public void GhostReader_ThrowsOnTooLargeString() + { + var hugeString = new string('A', 65536); + var user = new User { Name = hugeString }; + + var buffer = new byte[70000]; + + // Use simple try-catch to avoid any FluentAssertions complexity with wildcards + // or lambda capture issues if any remained (though strictly User is a class, so lambda is fine for User, but consistency is good). + Action act = () => user.WriteTo(buffer); + + act.Should().Throw() + .Where(e => e.Message.Contains("String length")); + } + + [Fact] + public void GhostReader_MultipleStrings_OverflowsBufferLimit() + { + // We need to push the _tailCursor past 65535. + // 1. FirstName: 30000 chars (~30002 bytes). Tail ~30020. + // 2. LastName: 30000 chars (~30002 bytes). Tail ~60040. + // 3. Description: 6000 chars. + // Start of Description write is 60040. OK. + // Tail becomes 66040. + // 4. We need a FOURTH string to fail? + // ComplexGhost has 3 strings: FirstName, LastName, Description. + // Wait, we check `if (_tailCursor > ushort.MaxValue)` at START of write. + + // If we want Description (3rd string) to fail, we need [1] + [2] > 65535. + // Let's make [1] = 40000. [2] = 30000. + // [1] writes 40000+. Tail -> 40000. + // [2] writes 30000+. Start is 40000 (OK). Tail -> 70000. + // [3] Description. Start is 70000. FAIL! + + var big1 = new string('X', 40000); + var big2 = new string('Y', 30000); // Only needs to be large enough to push tail + + var complex = new Complex + { + FirstName = big1, + LastName = big2, + Description = "This should fail to write because ptr is > 65535" + }; + + var buffer = new byte[80000]; // Plenty of space + + // Should fail because Description's start offset (pointer) would be > 65535 + Action act = () => complex.WriteTo(buffer); + + act.Should().Throw() + .Where(e => e.Message.Contains("Buffer offset")); + } + + [Fact] + public void GhostReader_Read_BufferTooSmall_Throws() + { + var user = new User { Id = 1, Score = 1.0 }; + byte[] validBytes = user.ToBytes(); + + // Create a truncated view + // We cannot capture 'ghost' (ref struct) in a lambda, so we test differently. + var truncatedSpan = validBytes.AsSpan(0, 5); + var ghost = new UserGhost(truncatedSpan); + + // Accessing ID (Offset 0, Size 4) -> Should work (0..4 fits in 5) + _ = ghost.Id; + + // Manually check for ArgumentOutOfRangeException since we can't use lambdas with ref structs + bool scoreThrow = false; + try { _ = ghost.Score; } + catch (ArgumentOutOfRangeException) { scoreThrow = true; } + scoreThrow.Should().BeTrue("accessing Score beyond buffer should throw"); + + bool nameThrow = false; + try { _ = ghost.Name; } + catch (ArgumentOutOfRangeException) { nameThrow = true; } + nameThrow.Should().BeTrue("accessing Name pointer beyond buffer should throw"); + } + } +} diff --git a/src/Rapp/Rapp.csproj b/src/Rapp/Rapp.csproj index 9edb778..ec7167a 100644 --- a/src/Rapp/Rapp.csproj +++ b/src/Rapp/Rapp.csproj @@ -1,12 +1,12 @@ Rapp - 1.1.0 + 1.2.0 Schema-aware binary serialization for safe, high-performance .NET caching caching;serialization;binary;performance;aot;schema-evolution icon.png README.md - v1.1.0 - Features full AOT compatibility, new Dashboard statistics, and enhanced documentation. Includes automatic schema validation and zero-overhead telemetry options. + v1.2.0 - Introduces Ghost Reader for zero-copy views. Includes automatic schema validation and zero-overhead telemetry options. true true $(TargetsForTfmSpecificBuildOutput);CopyProjectReferencesToPackage diff --git a/src/Rapp/RappGhostAttribute.cs b/src/Rapp/RappGhostAttribute.cs new file mode 100644 index 0000000..f410d70 --- /dev/null +++ b/src/Rapp/RappGhostAttribute.cs @@ -0,0 +1,13 @@ +using System; + +namespace Rapp +{ + /// + /// Opt-in attribute to generate a Zero-Copy "Ghost Reader" (ref struct) for this class. + /// The generated reader allows accessing properties directly from the raw binary buffer without object allocation. + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] + public class RappGhostAttribute : Attribute + { + } +} diff --git a/src/Rapp/RappWriter.cs b/src/Rapp/RappWriter.cs new file mode 100644 index 0000000..ed0a78a --- /dev/null +++ b/src/Rapp/RappWriter.cs @@ -0,0 +1,154 @@ +using System; +using System.Buffers.Binary; +using System.Text; + +namespace Rapp +{ + /// + /// Helper struct for writing data in the Rapp Ghost format (Fixed Head + Variable Tail). + /// + public ref struct RappWriter + { + private readonly Span _buffer; + private int _headCursor; // Tracks fixed fields + private int _tailCursor; // Tracks variable heap + + /// + /// Initializes a new instance of the struct. + /// + /// The target buffer to write to. + /// The pre-calculated size of the fixed "Head" region. + public RappWriter(Span buffer, int headSize) + { + _buffer = buffer; + _headCursor = 0; + _tailCursor = headSize; // Tail starts where Head ends + } + + // --- PRIMITIVES (Write to Head) --- + + /// + /// Writes a 32-bit integer to the fixed head region. + /// + public void WriteInt32(int value) + { + // Write value directly to the current fixed slot + BinaryPrimitives.WriteInt32LittleEndian(_buffer.Slice(_headCursor), value); + _headCursor += 4; + } + + /// + /// Writes a 64-bit integer to the fixed head region. + /// + public void WriteInt64(long value) + { + BinaryPrimitives.WriteInt64LittleEndian(_buffer.Slice(_headCursor), value); + _headCursor += 8; + } + + /// + /// Writes a 16-bit integer to the fixed head region. + /// + public void WriteInt16(short value) + { + BinaryPrimitives.WriteInt16LittleEndian(_buffer.Slice(_headCursor), value); + _headCursor += 2; + } + + /// + /// Writes a byte to the fixed head region. + /// + public void WriteByte(byte value) + { + _buffer[_headCursor] = value; + _headCursor += 1; + } + + /// + /// Writes a boolean to the fixed head region. + /// + public void WriteBool(bool value) + { + _buffer[_headCursor] = value ? (byte)1 : (byte)0; + _headCursor += 1; + } + + /// + /// Writes a double-precision floating-point number to the fixed head region. + /// + public void WriteDouble(double value) + { + long longVal = BitConverter.DoubleToInt64Bits(value); + BinaryPrimitives.WriteInt64LittleEndian(_buffer.Slice(_headCursor), longVal); + _headCursor += 8; + } + + /// + /// Writes a single-precision floating-point number to the fixed head region. + /// + public void WriteSingle(float value) // float + { + int intVal = BitConverter.SingleToInt32Bits(value); + BinaryPrimitives.WriteInt32LittleEndian(_buffer.Slice(_headCursor), intVal); + _headCursor += 4; + } + + // --- VARIABLE DATA (Write to Tail, Point from Head) --- + + /// + /// Writes a string to the variable tail region and updates the pointer in the head region. + /// Throws if buffer size or string length exceeds 65,535 bytes. + /// + public void WriteString(string? value) + { + if (value == null) + { + // Write 0 pointer + BinaryPrimitives.WriteUInt16LittleEndian( + _buffer.Slice(_headCursor), + 0 + ); + _headCursor += 2; + return; + } + + // Safety Check: BUFFER SIZE + if (_tailCursor > ushort.MaxValue) + { + throw new InvalidOperationException($"Rapp Ghost Reader limit exceeded: Buffer offset {_tailCursor} > 65535."); + } + + // 1. POINTER: Write the current Tail position into the Head + BinaryPrimitives.WriteUInt16LittleEndian( + _buffer.Slice(_headCursor), + (ushort)_tailCursor + ); + _headCursor += 2; + + // 2. LENGTH: Write the string byte count at the start of the Tail + int byteCount = Encoding.UTF8.GetByteCount(value); + + // Safety Check: STRING SIZE + if (byteCount > ushort.MaxValue) + { + throw new InvalidOperationException($"Rapp Ghost Reader limit exceeded: String length {byteCount} > 65535 bytes."); + } + + BinaryPrimitives.WriteUInt16LittleEndian( + _buffer.Slice(_tailCursor), + (ushort)byteCount + ); + + // 3. DATA: Write the actual characters after the length + Encoding.UTF8.GetBytes(value, _buffer.Slice(_tailCursor + 2)); + + // 4. UPDATE: Move the Tail cursor forward + _tailCursor += (2 + byteCount); + } + + /// + /// Gets the total number of bytes written to the buffer. + /// + public int Length => _tailCursor; + } +}