mirror of
https://github.com/Heretek-AI/game-engine.git
synced 2026-11-07 10:55:58 -10:00
parity: 133 ENet transport, snapshot replication, RPCs (ABI 4.11)
ENet v1.3.18 via FetchContent (pinned tag + SHA256 in native/net/CMakeLists.txt). NetSession: server/client sessions over loopback, snapshots on channel 0 (unsequenced) with per-word delta compression + NAK-all resync, RPCs on channel 1 (reliable), send-side loss injection for snapshots only. ABI 4.11 tail: net_create_server/client/destroy/connected/poll/ snapshot_push/pull/rpc_send/poll/set_loss + EngineNetTransformRow (48B). C# side: ReplicatedAttribute + NetworkSession wrapper; abi_codegen extended (uint16_t, EngineNetSession/NetTransformRow types, 48B row mirror, loader guard). deps/README.md row + THIRD_PARTY_NOTICES.md entry. Tests: engine_net_replication (100 transforms converge within 10 ticks @10% loss, delta gates, reliable RPCs both directions) and abi_layout tail pins.
This commit is contained in:
1 parent
4f76f87f51
commit
c5371e834c
16 files changed
+1612
-15
No files matched your search
@@ -121,3 +121,25 @@ freely, subject to the following restrictions:
|
||||
3. This notice may not be removed or altered from any source
|
||||
distribution.
|
||||
```
|
||||
|
||||
## ENet v1.3.18 (P7-04)
|
||||
|
||||
- Upstream: https://github.com/lsalzman/enet
|
||||
- Tarball: https://github.com/lsalzman/enet/archive/refs/tags/v1.3.18.tar.gz
|
||||
- SHA256: `28603c895f9ed24a846478180ee72c7376b39b4bb1287b73877e5eae7d96b0dd`
|
||||
- License: MIT (see below; build-tree only, never vendored — fetched at
|
||||
configure into the build dir via FetchContent,
|
||||
`native/net/CMakeLists.txt`).
|
||||
- Targets used: `enet` (explicit source list from the fetched tree;
|
||||
upstream CMakeLists not added — it installs unconditionally).
|
||||
- Full license text: https://github.com/lsalzman/enet/blob/v1.3.18/LICENSE
|
||||
|
||||
```
|
||||
Copyright (c) 2002-2024 Lee Salzman
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
```
|
||||
Vendored
+1
@@ -28,3 +28,4 @@ rm -rf deps/flecs/.git deps/enkiTS/.git
|
||||
# then update the pin table above with the new tag/SHA
|
||||
```
|
||||
| Box2D | `v3.2.0` | https://github.com/erincatto/box2d | MIT | P6-07 (fetched at configure via FetchContent, pinned tag + SHA256 in `native/physics/CMakeLists.txt`; build-tree only, never vendored) |
|
||||
| ENet | `v1.3.18` | https://github.com/lsalzman/enet/archive/refs/tags/v1.3.18.tar.gz (SHA256 `28603c895f9ed24a846478180ee72c7376b39b4bb1287b73877e5eae7d96b0dd`) | MIT | P7-04 (fetched at configure via FetchContent, pinned tag + SHA256 in `native/net/CMakeLists.txt`; build-tree only, never vendored); target `enet` (explicit source list, engine adapter in `native/net/`) |
|
||||
@@ -1,7 +1,7 @@
|
||||
// <auto-generated>
|
||||
// NativeApi.g.cs — GENERATED by tools/abi_codegen from
|
||||
// native/bridge/include/engine/bridge/engine_abi.h. DO NOT EDIT.
|
||||
// ABI 4.10.0, column stride 64.
|
||||
// ABI 4.11.0, column stride 64.
|
||||
// Regenerate: dotnet run --project tools/abi_codegen -- <repo-root>
|
||||
// </auto-generated>
|
||||
#nullable enable
|
||||
@@ -25,7 +25,7 @@ namespace Engine.Bindings;
|
||||
public static unsafe class NativeApi
|
||||
{
|
||||
public const int AbiVersionMajor = 4;
|
||||
public const int AbiVersionMinor = 10;
|
||||
public const int AbiVersionMinor = 11;
|
||||
public const int AbiVersionPatch = 0;
|
||||
public const int ColumnStride = 64;
|
||||
public const string NativeLibraryName = "engine_core";
|
||||
@@ -81,6 +81,13 @@ public static unsafe class NativeApi
|
||||
public void* Handle;
|
||||
}
|
||||
|
||||
/// <summary>Opaque network session (owns one ENet host + baselines + queues, P7-04).</summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 8)]
|
||||
public unsafe struct EngineNetSession
|
||||
{
|
||||
public void* Handle;
|
||||
}
|
||||
|
||||
/// <summary>Opaque native physics world handle (caller-owned engine::physics::PhysicsWorld, not the bare JPH::PhysicsSystem).</summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 8)]
|
||||
public unsafe struct EnginePhysicsWorld
|
||||
@@ -322,6 +329,14 @@ public static unsafe class NativeApi
|
||||
public fixed uint PadButtons[4];
|
||||
}
|
||||
|
||||
/// <summary>Mirror of EngineNetTransformRow: entity + 10 replicated words, 48 bytes.</summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 8, Size = 48)]
|
||||
public unsafe struct NetTransformRow
|
||||
{
|
||||
public ulong Entity;
|
||||
public fixed float Words[10];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Managed per-chunk system signature (ABI 2: view + frame dt by value).
|
||||
/// Implement as a static method marked
|
||||
@@ -436,6 +451,16 @@ public static unsafe class NativeApi
|
||||
public delegate* unmanaged[Cdecl]<EngineEditorSession*, ulong, ulong*, int> EngineEditorDuplicate;
|
||||
public delegate* unmanaged[Cdecl]<EngineEditorSession*, ulong, EngineEditorSnapshot*> EngineEditorSnapshotTakeSubtree;
|
||||
public delegate* unmanaged[Cdecl]<EngineEditorSession*, EngineEditorSnapshot*, ulong, int> EngineEditorSnapshotRestoreSubtree;
|
||||
public delegate* unmanaged[Cdecl]<ushort, EngineNetSession*> EngineNetCreateServer;
|
||||
public delegate* unmanaged[Cdecl]<ushort, EngineNetSession*> EngineNetCreateClient;
|
||||
public delegate* unmanaged[Cdecl]<EngineNetSession*, void> EngineNetDestroy;
|
||||
public delegate* unmanaged[Cdecl]<EngineNetSession*, int> EngineNetConnected;
|
||||
public delegate* unmanaged[Cdecl]<EngineNetSession*, uint, int> EngineNetPoll;
|
||||
public delegate* unmanaged[Cdecl]<EngineNetSession*, NetTransformRow*, uint, int, uint> EngineNetSnapshotPush;
|
||||
public delegate* unmanaged[Cdecl]<EngineNetSession*, NetTransformRow*, uint, uint> EngineNetSnapshotPull;
|
||||
public delegate* unmanaged[Cdecl]<EngineNetSession*, uint, void*, uint, int> EngineNetRpcSend;
|
||||
public delegate* unmanaged[Cdecl]<EngineNetSession*, uint*, void*, uint, int> EngineNetRpcPoll;
|
||||
public delegate* unmanaged[Cdecl]<EngineNetSession*, float, uint, int> EngineNetSetLoss;
|
||||
}
|
||||
|
||||
/// <summary>Resolves engine_get_api_table and validates the ABI.</summary>
|
||||
@@ -484,9 +509,9 @@ public static unsafe class NativeApi
|
||||
throw new InvalidOperationException($"ABI column stride mismatch: native {table->ColumnStride} != managed {ColumnStride}.");
|
||||
}
|
||||
|
||||
if (sizeof(LocalTransform) != 64 || sizeof(WorldTransform) != 64 || sizeof(Ray) != 24 || sizeof(RaycastHit) != 48 || sizeof(EngineChunkColumns) != 120 || sizeof(InputState) != 1668 || sizeof(NavBox) != 24 || sizeof(NavBakeConfig) != 48 || sizeof(NavStats) != 8 || sizeof(NavAgentData) != 64)
|
||||
if (sizeof(LocalTransform) != 64 || sizeof(WorldTransform) != 64 || sizeof(Ray) != 24 || sizeof(RaycastHit) != 48 || sizeof(EngineChunkColumns) != 120 || sizeof(InputState) != 1668 || sizeof(NavBox) != 24 || sizeof(NavBakeConfig) != 48 || sizeof(NavStats) != 8 || sizeof(NavAgentData) != 64 || sizeof(NetTransformRow) != 48)
|
||||
{
|
||||
throw new InvalidOperationException($"Component layout drift: LocalTransform={sizeof(LocalTransform)} WorldTransform={sizeof(WorldTransform)} Ray={sizeof(Ray)} Hit={sizeof(RaycastHit)} ChunkColumns={sizeof(EngineChunkColumns)} InputState={sizeof(InputState)} NavBox={sizeof(NavBox)} NavConfig={sizeof(NavBakeConfig)} NavStats={sizeof(NavStats)} NavAgent={sizeof(NavAgentData)}.");
|
||||
throw new InvalidOperationException($"Component layout drift: LocalTransform={sizeof(LocalTransform)} WorldTransform={sizeof(WorldTransform)} Ray={sizeof(Ray)} Hit={sizeof(RaycastHit)} ChunkColumns={sizeof(EngineChunkColumns)} InputState={sizeof(InputState)} NavBox={sizeof(NavBox)} NavConfig={sizeof(NavBakeConfig)} NavStats={sizeof(NavStats)} NavAgent={sizeof(NavAgentData)} NetRow={sizeof(NetTransformRow)}.");
|
||||
}
|
||||
|
||||
_table = table;
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
// Networking.cs — P7-04: ENet sessions, snapshot replication, reliable RPCs.
|
||||
//
|
||||
// Managed wrapper over the ABI 4.11 networking tail (net_create_server /
|
||||
// create_client / destroy / connected / poll / snapshot_push / snapshot_pull /
|
||||
// rpc_send / rpc_poll / set_loss). Sessions do socket I/O (service may block)
|
||||
// so all entries are plain Cdecl — never SuppressGCTransition.
|
||||
//
|
||||
// ReplicatedAttribute marks a component struct for snapshot replication: the
|
||||
// server serializes its 10 LocalTransform words (px..sz) per tick with delta
|
||||
// compression against the last-sent baseline; the client applies deltas onto
|
||||
// its per-entity baseline and reads FULL 10-word rows. RPCs ride the reliable
|
||||
// channel FIFO per sender. All types blittable; no managed state on the frame
|
||||
// path (mirrors the native NetSession layout contract).
|
||||
using System.Runtime.InteropServices;
|
||||
using Engine.Bindings;
|
||||
|
||||
namespace Engine.Runtime;
|
||||
|
||||
/// <summary>
|
||||
/// Marks a component struct as network-replicated: the server serializes its
|
||||
/// 10 LocalTransform words per tick (delta-compressed); the client applies
|
||||
/// deltas and reads full rows. Blittable 10-word payload (px..sz).
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Struct | AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
|
||||
public sealed class ReplicatedAttribute : Attribute
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Managed owner of one network session (server or client). Owns the native
|
||||
/// session lifetime; destroy is idempotent. TableOverride allows tests to pin
|
||||
/// an absolute-path library handle instead of ApiLoader.
|
||||
/// </summary>
|
||||
public sealed unsafe class NetworkSession : IDisposable
|
||||
{
|
||||
// Test seam: absolute-path library handle instead of ApiLoader.
|
||||
#pragma warning disable CA2211
|
||||
public static IntPtr TableOverride = IntPtr.Zero;
|
||||
#pragma warning restore CA2211
|
||||
|
||||
private static unsafe NativeApi.ApiTable* TablePtr =>
|
||||
TableOverride != IntPtr.Zero ? (NativeApi.ApiTable*)TableOverride : NativeApi.ApiLoader.Table;
|
||||
|
||||
private NativeApi.EngineNetSession* _session;
|
||||
private bool _disposed;
|
||||
|
||||
private NetworkSession(NativeApi.EngineNetSession* session)
|
||||
{
|
||||
_session = session;
|
||||
}
|
||||
|
||||
/// <summary>Creates a server session bound to the loopback port. Null on failure (bad port 0, init/host failure, double-create).</summary>
|
||||
public static NetworkSession? CreateServer(ushort port)
|
||||
{
|
||||
var s = TablePtr->EngineNetCreateServer(port);
|
||||
return s == null ? null : new NetworkSession(s);
|
||||
}
|
||||
|
||||
/// <summary>Creates a client session connecting to 127.0.0.1:port. Completes async: poll until Connected.</summary>
|
||||
public static NetworkSession? CreateClient(ushort port)
|
||||
{
|
||||
var s = TablePtr->EngineNetCreateClient(port);
|
||||
return s == null ? null : new NetworkSession(s);
|
||||
}
|
||||
|
||||
/// <summary>True once a peer is linked (server: >=1 client; client: server peer CONNECTED).</summary>
|
||||
public bool Connected => _session != null && TablePtr->EngineNetConnected(_session) != 0;
|
||||
|
||||
/// <summary>Pumps ENet service. Returns false on null session.</summary>
|
||||
public bool Poll(uint timeoutMs = 0)
|
||||
{
|
||||
if (_session == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return TablePtr->EngineNetPoll(_session, timeoutMs) == 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server side: serializes one tick for count rows (forceFull on tick 0 /
|
||||
/// after NAK resync). Returns rows queued. Zero rows on no-change
|
||||
/// (mask-all-clear skip). Uses spans — zero managed alloc.
|
||||
/// </summary>
|
||||
public uint SnapshotPush(ReadOnlySpan<NativeApi.NetTransformRow> rows, bool forceFull)
|
||||
{
|
||||
if (_session == null || rows.IsEmpty)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
fixed (NativeApi.NetTransformRow* p = rows)
|
||||
{
|
||||
return TablePtr->EngineNetSnapshotPush(_session, p, (uint)rows.Length, forceFull ? 1 : 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client side: drains queued snapshots into out (capacity rows, FULL
|
||||
/// 10-word values). Returns rows written.
|
||||
/// </summary>
|
||||
public uint SnapshotPull(Span<NativeApi.NetTransformRow> outRows)
|
||||
{
|
||||
if (_session == null || outRows.IsEmpty)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
fixed (NativeApi.NetTransformRow* p = outRows)
|
||||
{
|
||||
return TablePtr->EngineNetSnapshotPull(_session, p, (uint)outRows.Length);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Sends one reliable RPC (size <= 1024). Returns true when queued. Loss injection does NOT apply.</summary>
|
||||
public bool RpcSend(uint id, ReadOnlySpan<byte> data)
|
||||
{
|
||||
if (_session == null || data.Length > 1024)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
fixed (byte* p = data)
|
||||
{
|
||||
return TablePtr->EngineNetRpcSend(_session, id, p, (uint)data.Length) == 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drains one queued RPC: returns bytes written (>0), 0 when none
|
||||
/// pending, negative errno-style on bad args (-1 null args, -2 caller
|
||||
/// buffer too small; entry kept on -2).
|
||||
/// </summary>
|
||||
public int RpcPoll(out uint id, Span<byte> outBuf)
|
||||
{
|
||||
id = 0;
|
||||
if (_session == null)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
uint rid = 0;
|
||||
int n;
|
||||
fixed (byte* p = outBuf)
|
||||
{
|
||||
n = TablePtr->EngineNetRpcPoll(_session, &rid, p, (uint)outBuf.Length);
|
||||
}
|
||||
|
||||
if (n > 0)
|
||||
{
|
||||
id = rid;
|
||||
}
|
||||
|
||||
return n;
|
||||
}
|
||||
|
||||
/// <summary>Uniform send-side drop probability [0,1] applied to snapshot sends only (RPC/control stay reliable).</summary>
|
||||
public bool SetLoss(float probability, uint seed)
|
||||
{
|
||||
if (_session == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return TablePtr->EngineNetSetLoss(_session, probability, seed) == 0;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
if (_session != null)
|
||||
{
|
||||
TablePtr->EngineNetDestroy(_session);
|
||||
_session = null;
|
||||
}
|
||||
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
~NetworkSession()
|
||||
{
|
||||
if (_session != null)
|
||||
{
|
||||
TablePtr->EngineNetDestroy(_session);
|
||||
_session = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ public static class AbiLayoutTests
|
||||
{
|
||||
var failures = 0;
|
||||
failures += Check(NativeApi.AbiVersionMajor == 4, "abi major == 4");
|
||||
failures += Check(NativeApi.AbiVersionMinor == 10, "abi minor == 10 (4.10: P3-03 hierarchy tail after 4.9 2D physics)");
|
||||
failures += Check(NativeApi.AbiVersionMinor == 11, "abi minor == 11 (4.11: P7-04 networking tail after 4.10 hierarchy)");
|
||||
failures += Check(NativeApi.AbiVersionPatch == 0, "abi patch == 0");
|
||||
failures += Check(NativeApi.ColumnStride == 64, "column stride == 64");
|
||||
|
||||
@@ -35,7 +35,7 @@ public static class AbiLayoutTests
|
||||
failures += Check(sizeof(NativeApi.WorldTransform) == 64, "WorldTransform 64B");
|
||||
failures += Check(sizeof(NativeApi.EcsChunkView) == 48, "EcsChunkView 48B");
|
||||
failures += Check(sizeof(NativeApi.FrameDispatch) == 4, "FrameDispatch 4B");
|
||||
failures += Check(sizeof(NativeApi.ApiTable) == 712, "ApiTable 712B (ABI 4.10: +5 hierarchy entries)");
|
||||
failures += Check(sizeof(NativeApi.ApiTable) == 792, "ApiTable 792B (ABI 4.11: +10 networking entries)");
|
||||
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineEditorDestroyEntity").ToInt64() == 672, "editor_destroy_entity @ 672 (ABI 4.10 tail)");
|
||||
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineEditorSnapshotRestoreSubtree").ToInt64() == 704, "snapshot_restore_subtree @ 704 (ABI 4.10 tail end)");
|
||||
failures += Check(sizeof(NativeApi.InputState) == 1668, "InputState 1668B (P2-02 snapshot)");
|
||||
@@ -76,6 +76,11 @@ public static class AbiLayoutTests
|
||||
failures += Check(sizeof(NativeApi.NavStats) == 8, "NavStats 8B");
|
||||
failures += Check(sizeof(NativeApi.NavAgentData) == 64, "NavAgentData 64B");
|
||||
failures += Check(sizeof(Engine.Runtime.NavAgent) == 64, "Runtime.NavAgent 64B");
|
||||
failures += Check(sizeof(NativeApi.EngineNetSession) == 8, "EngineNetSession opaque 8B");
|
||||
failures += Check(sizeof(NativeApi.NetTransformRow) == 48, "NetTransformRow 48B");
|
||||
failures += Check(Marshal.OffsetOf<NativeApi.NetTransformRow>("Entity").ToInt64() == 0, "NetTransformRow.Entity @ 0");
|
||||
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineNetCreateServer").ToInt64() == 712, "net_create_server @ 712 (ABI 4.11 tail)");
|
||||
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineNetSetLoss").ToInt64() == 784, "net_set_loss @ 784 (ABI 4.11 tail end)");
|
||||
}
|
||||
|
||||
failures += Check(Marshal.OffsetOf<NativeApi.EngineChunkColumns>("Entities").ToInt64() == 96, "columns.Entities @ 96");
|
||||
|
||||
@@ -20,7 +20,7 @@ add_subdirectory(physics)
|
||||
add_subdirectory(audio)
|
||||
add_subdirectory(assets)
|
||||
add_subdirectory(navigation)
|
||||
|
||||
add_subdirectory(net)
|
||||
add_subdirectory(terrain)
|
||||
add_subdirectory(bridge)
|
||||
|
||||
@@ -47,7 +47,7 @@ target_link_libraries(engine_core PUBLIC
|
||||
engine_core_audio
|
||||
engine_core_assets
|
||||
engine_core_navigation
|
||||
|
||||
engine_core_net
|
||||
engine_core_terrain
|
||||
engine_core_bridge
|
||||
)
|
||||
|
||||
@@ -108,7 +108,7 @@ target_compile_definitions(engine_core_bridge PRIVATE ENGINE_CORE_BUILDING)
|
||||
# sessions + the P3-08 project-drop entry (ImportFast over staged asset
|
||||
# files) + the P6-03 navigation tail: needs ecs/core + renderer + physics +
|
||||
# assets + navigation headers and the shared flecs target transitively.
|
||||
target_link_libraries(engine_core_bridge PUBLIC engine_core_ecs engine_core_renderer engine_core_physics engine_core_assets engine_core_navigation)
|
||||
target_link_libraries(engine_core_bridge PUBLIC engine_core_ecs engine_core_renderer engine_core_physics engine_core_assets engine_core_navigation engine_core_net)
|
||||
|
||||
# nethost: get_hostfxr_path for hostfxr discovery (M1-05 DotNetHost).
|
||||
# Static libnethost.a keeps engine_core dependency-free at load time;
|
||||
|
||||
@@ -129,9 +129,16 @@ extern "C" {
|
||||
// 4.3; renumbered to 4.10 so every earlier offset stays frozen.
|
||||
// editor_destroy_entity cascades to descendants, unlike the 4.7
|
||||
// editor_delete_entity (which orphans children to roots).
|
||||
//
|
||||
// ABI 4.11.0: appends the P7-04 networking tail (net_create_server/
|
||||
// create_client/destroy/connected/poll/snapshot_push/snapshot_pull/
|
||||
// rpc_send/rpc_poll/set_loss) AFTER the 4.10 hierarchy tail
|
||||
// (editor_snapshot_restore_subtree). ENet v1.3.18 transport (§4.15.1),
|
||||
// delta-compressed transform snapshots, reliable RPCs. All earlier
|
||||
// entries keep order + signature; new fields extend the tail.
|
||||
// ---------------------------------------------------------------------------
|
||||
#define ENGINE_ABI_VERSION_MAJOR 4
|
||||
#define ENGINE_ABI_VERSION_MINOR 10
|
||||
#define ENGINE_ABI_VERSION_MINOR 11
|
||||
#define ENGINE_ABI_VERSION_PATCH 0
|
||||
|
||||
// 64-byte cache-line pitch for the component columns handed to managed
|
||||
@@ -1028,6 +1035,80 @@ typedef uint32_t (*EnginePhysics2DDrainFn)(EnginePhysics2DWorld* world,
|
||||
EngineCollisionEvent2D* out,
|
||||
uint32_t capacity);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// P7-04 networking tail (ABI 4.11): ENet sessions, snapshot replication
|
||||
// with delta compression, reliable RPCs.
|
||||
//
|
||||
// A session owns one ENetHost (server: bound loopback port, up to 32
|
||||
// peers; client: unbound + one server peer). Wire channels: 0 =
|
||||
// snapshots (unsequenced-unreliable), 1 = control/RPC (reliable).
|
||||
// Loss is injected on SEND in the adapter (uniform LCG, seedable) for
|
||||
// the 10%-loss acceptance test; it applies to snapshots only.
|
||||
//
|
||||
// Snapshots replicate the 10 LocalTransform words (px..sz); pad is
|
||||
// never sent. push computes a per-row changed-word bitmask against the
|
||||
// last-sent baseline (force_full or NAK resync sends all 10); pull
|
||||
// applies deltas onto the receiver baseline and emits FULL 10-word
|
||||
// rows. RPCs ride the reliable channel FIFO per sender.
|
||||
// All entries return 0/NULL on null handles or bad args (no crash);
|
||||
// destroy is idempotent. See native/net/include/engine/net/net_session.hpp
|
||||
// for the wire formats.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Opaque network session (owns one ENetHost + baselines + queues).
|
||||
typedef struct EngineNetSession EngineNetSession;
|
||||
|
||||
// Server: bind loopback `port`, up to 32 peers. Client: connect to
|
||||
// 127.0.0.1:`port` (completes async: poll until connected). NULL on
|
||||
// failure (bad port 0, init/host failure, double-create).
|
||||
typedef EngineNetSession* (*EngineNetCreateServerFn)(uint16_t port);
|
||||
typedef EngineNetSession* (*EngineNetCreateClientFn)(uint16_t port);
|
||||
typedef void (*EngineNetDestroyFn)(EngineNetSession* session);
|
||||
|
||||
// 1 once a peer is linked (server: >=1 client; client: server peer
|
||||
// CONNECTED), else 0. NULL session -> 0.
|
||||
typedef int32_t (*EngineNetConnectedFn)(const EngineNetSession* session);
|
||||
|
||||
// Pump ENet service (`timeout_ms`, 0 = non-blocking). Returns 0 on
|
||||
// success, nonzero on null session.
|
||||
typedef int32_t (*EngineNetPollFn)(EngineNetSession* session,
|
||||
uint32_t timeout_ms);
|
||||
|
||||
// One replicated transform row: entity + 10 LocalTransform words.
|
||||
typedef struct EngineNetTransformRow {
|
||||
uint64_t entity;
|
||||
float words[10];
|
||||
} EngineNetTransformRow;
|
||||
|
||||
// Server side: serialize one tick for `count` rows (force_full on tick
|
||||
// 0 / after NAK). Returns rows queued (0 on null session/args).
|
||||
typedef uint32_t (*EngineNetSnapshotPushFn)(
|
||||
EngineNetSession* session, const EngineNetTransformRow* rows,
|
||||
uint32_t count, int32_t force_full);
|
||||
|
||||
// Client side: drain queued snapshots into `out` (capacity rows,
|
||||
// FULL 10-word values). Returns rows written.
|
||||
typedef uint32_t (*EngineNetSnapshotPullFn)(EngineNetSession* session,
|
||||
EngineNetTransformRow* out,
|
||||
uint32_t capacity);
|
||||
|
||||
// Send one reliable RPC (size <= 1024). Returns 0 on success.
|
||||
typedef int32_t (*EngineNetRpcSendFn)(EngineNetSession* session,
|
||||
uint32_t id, const void* data,
|
||||
uint32_t size);
|
||||
|
||||
// Drain one queued RPC: `out_id` receives the id; returns bytes
|
||||
// written (>0), 0 when none pending, negative errno-style on bad args
|
||||
// (-1 null args, -2 caller buffer too small; entry kept on -2).
|
||||
typedef int32_t (*EngineNetRpcPollFn)(EngineNetSession* session,
|
||||
uint32_t* out_id, void* out,
|
||||
uint32_t capacity);
|
||||
|
||||
// Uniform send-side drop probability [0,1] for snapshot sends
|
||||
// (RPC/control stay reliable). Returns 0 on success.
|
||||
typedef int32_t (*EngineNetSetLossFn)(EngineNetSession* session,
|
||||
float probability, uint32_t seed);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EngineApiTable — the single-entry-point ABI surface.
|
||||
//
|
||||
@@ -1147,6 +1228,17 @@ typedef struct EngineApiTable {
|
||||
EngineEditorDuplicateFn editor_duplicate;
|
||||
EngineEditorSnapshotTakeSubtreeFn editor_snapshot_take_subtree;
|
||||
EngineEditorSnapshotRestoreSubtreeFn editor_snapshot_restore_subtree;
|
||||
// P7-04 Networking tail (ABI 4.11). All earlier order frozen.
|
||||
EngineNetCreateServerFn net_create_server;
|
||||
EngineNetCreateClientFn net_create_client;
|
||||
EngineNetDestroyFn net_destroy;
|
||||
EngineNetConnectedFn net_connected;
|
||||
EngineNetPollFn net_poll;
|
||||
EngineNetSnapshotPushFn net_snapshot_push;
|
||||
EngineNetSnapshotPullFn net_snapshot_pull;
|
||||
EngineNetRpcSendFn net_rpc_send;
|
||||
EngineNetRpcPollFn net_rpc_poll;
|
||||
EngineNetSetLossFn net_set_loss;
|
||||
|
||||
} EngineApiTable;
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ struct enkiTaskScheduler;
|
||||
#include "console_log.hpp"
|
||||
#include "engine/bridge/dotnet_host.hpp"
|
||||
#include "engine/ecs/editor_bridge.hpp"
|
||||
#include "engine/net/net_session.hpp"
|
||||
#include "engine/core/engine_core_export.hpp"
|
||||
#include "engine/core/job_scheduler.hpp"
|
||||
#include "engine/ecs/ecs_world.hpp"
|
||||
@@ -81,6 +82,12 @@ static_assert(offsetof(EngineCollisionEvent2D, kind) ==
|
||||
static_assert(sizeof(EcsChunkView) == 48, "EcsChunkView must stay 48 bytes");
|
||||
static_assert(offsetof(EcsChunkView, count) == 24,
|
||||
"EcsChunkView.count offset pinned at 24");
|
||||
// P7-04 networking: ABI snapshot row == session wire row (entity + 10
|
||||
// LocalTransform words; directly reinterpreted in ApiNetSnapshotPush).
|
||||
static_assert(sizeof(EngineNetTransformRow) == 48,
|
||||
"EngineNetTransformRow must be 48 bytes (u64 + 10 floats)");
|
||||
static_assert(offsetof(EngineNetTransformRow, words) == 8,
|
||||
"EngineNetTransformRow.words @ 8");
|
||||
static_assert(ENGINE_ABI_COLUMN_STRIDE == 64, "column stride pinned at 64");
|
||||
// ABI 4.2 generic view: 64-bit only (8 pointers + 8 sizes + tail = 120B).
|
||||
static_assert(sizeof(void*) == 8, "engine ABI is 64-bit only");
|
||||
@@ -1655,6 +1662,145 @@ auto ApiEditorSnapshotRestoreSubtree(EngineEditorSession* session, EngineEditorS
|
||||
}
|
||||
}
|
||||
|
||||
// P7-04 networking: thin noexcept wrappers over engine::net::NetSession
|
||||
// (native/net). Sessions do socket I/O (service may block) so these are
|
||||
// plain host-tooling entries, never frame-path. Row layout: ABI mirror
|
||||
// EngineNetTransformRow == session 10-word rows (static_assert below).
|
||||
auto SessionOf(EngineNetSession* s) -> engine::net::NetSession* {
|
||||
return reinterpret_cast<engine::net::NetSession*>(s); // NOLINT
|
||||
}
|
||||
|
||||
auto ApiNetCreateServer(std::uint16_t port) -> EngineNetSession* {
|
||||
if (port == 0) {
|
||||
return nullptr;
|
||||
}
|
||||
auto* impl = new (std::nothrow) engine::net::NetSession();
|
||||
if (impl == nullptr) {
|
||||
return nullptr;
|
||||
}
|
||||
if (!impl->create_server(port)) {
|
||||
delete impl; // NOLINT
|
||||
return nullptr;
|
||||
}
|
||||
return reinterpret_cast<EngineNetSession*>(impl); // NOLINT
|
||||
}
|
||||
|
||||
auto ApiNetCreateClient(std::uint16_t port) -> EngineNetSession* {
|
||||
if (port == 0) {
|
||||
return nullptr;
|
||||
}
|
||||
auto* impl = new (std::nothrow) engine::net::NetSession();
|
||||
if (impl == nullptr) {
|
||||
return nullptr;
|
||||
}
|
||||
if (!impl->create_client(port)) {
|
||||
delete impl; // NOLINT
|
||||
return nullptr;
|
||||
}
|
||||
return reinterpret_cast<EngineNetSession*>(impl); // NOLINT
|
||||
}
|
||||
|
||||
auto ApiNetDestroy(EngineNetSession* session) -> void {
|
||||
if (session == nullptr) {
|
||||
return;
|
||||
}
|
||||
auto* impl = SessionOf(session);
|
||||
delete impl; // NOLINT (destructor calls destroy())
|
||||
}
|
||||
|
||||
auto ApiNetConnected(const EngineNetSession* session) -> int32_t {
|
||||
if (session == nullptr) {
|
||||
return 0;
|
||||
}
|
||||
const auto* impl = reinterpret_cast<const engine::net::NetSession*>(session); // NOLINT
|
||||
return impl->connected() ? 1 : 0;
|
||||
}
|
||||
|
||||
auto ApiNetPoll(EngineNetSession* session, std::uint32_t timeout_ms) -> int32_t {
|
||||
auto* impl = SessionOf(session);
|
||||
if (impl == nullptr) {
|
||||
return -1;
|
||||
}
|
||||
return impl->poll(timeout_ms) ? 0 : -1;
|
||||
}
|
||||
|
||||
auto ApiNetSnapshotPush(EngineNetSession* session,
|
||||
const EngineNetTransformRow* rows, std::uint32_t count,
|
||||
int32_t force_full) -> std::uint32_t {
|
||||
auto* impl = SessionOf(session);
|
||||
if (impl == nullptr || (rows == nullptr && count > 0)) {
|
||||
return 0;
|
||||
}
|
||||
if (rows == nullptr || count == 0) {
|
||||
return 0;
|
||||
}
|
||||
// Bridge may allocate (host path, never frame path): split entities
|
||||
// out; the 10-word payload rows reinterpret directly (ABI row pitch
|
||||
// == session row pitch: u64 entity + 10 floats; static_assert below).
|
||||
std::vector<std::uint64_t> entities;
|
||||
try {
|
||||
entities.reserve(count);
|
||||
for (std::uint32_t i = 0; i < count; ++i) {
|
||||
entities.push_back(rows[i].entity);
|
||||
}
|
||||
} catch (...) {
|
||||
return 0;
|
||||
}
|
||||
const auto* payload = reinterpret_cast<const float(*)[10]>(&rows[0].words); // NOLINT
|
||||
return impl->snapshot_push(entities.data(), payload, count,
|
||||
force_full != 0);
|
||||
}
|
||||
|
||||
auto ApiNetSnapshotPull(EngineNetSession* session, EngineNetTransformRow* out,
|
||||
std::uint32_t capacity) -> std::uint32_t {
|
||||
auto* impl = SessionOf(session);
|
||||
if (impl == nullptr || out == nullptr || capacity == 0) {
|
||||
return 0;
|
||||
}
|
||||
// Pull straight into the ABI rows: entity lands first, then the
|
||||
// 10-word payload (same pitch as the session PendingRow).
|
||||
std::vector<std::uint64_t> entities;
|
||||
try {
|
||||
entities.resize(capacity);
|
||||
} catch (...) {
|
||||
return 0;
|
||||
}
|
||||
auto* payload = reinterpret_cast<float(*)[10]>(&out[0].words); // NOLINT
|
||||
std::uint32_t n = impl->snapshot_pull(entities.data(), payload, capacity);
|
||||
for (std::uint32_t i = 0; i < n; ++i) {
|
||||
out[i].entity = entities[i];
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
auto ApiNetRpcSend(EngineNetSession* session, std::uint32_t id,
|
||||
const void* data, std::uint32_t size) -> int32_t {
|
||||
auto* impl = SessionOf(session);
|
||||
if (impl == nullptr) {
|
||||
return -1;
|
||||
}
|
||||
return impl->rpc_send(id, data, size) ? 0 : -1;
|
||||
}
|
||||
|
||||
auto ApiNetRpcPoll(EngineNetSession* session, std::uint32_t* out_id,
|
||||
void* out, std::uint32_t capacity) -> int32_t {
|
||||
auto* impl = SessionOf(session);
|
||||
if (impl == nullptr || out_id == nullptr) {
|
||||
return -1;
|
||||
}
|
||||
return impl->rpc_poll(out_id, out, capacity);
|
||||
}
|
||||
|
||||
auto ApiNetSetLoss(EngineNetSession* session, float probability,
|
||||
std::uint32_t seed) -> int32_t {
|
||||
auto* impl = SessionOf(session);
|
||||
if (impl == nullptr) {
|
||||
return -1;
|
||||
}
|
||||
impl->set_loss(probability, seed);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const EngineApiTable kApiTable = {
|
||||
sizeof(EngineApiTable),
|
||||
ENGINE_ABI_VERSION_MAJOR,
|
||||
@@ -1751,6 +1897,16 @@ const EngineApiTable kApiTable = {
|
||||
&ApiEditorDuplicate,
|
||||
&ApiEditorSnapshotTakeSubtree,
|
||||
&ApiEditorSnapshotRestoreSubtree,
|
||||
&ApiNetCreateServer,
|
||||
&ApiNetCreateClient,
|
||||
&ApiNetDestroy,
|
||||
&ApiNetConnected,
|
||||
&ApiNetPoll,
|
||||
&ApiNetSnapshotPush,
|
||||
&ApiNetSnapshotPull,
|
||||
&ApiNetRpcSend,
|
||||
&ApiNetRpcPoll,
|
||||
&ApiNetSetLoss,
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# native/net — ENet transport + snapshot replication + RPCs. Owner: P7-04.
|
||||
#
|
||||
# ENet v1.3.18 arrives via FetchContent (pinned tag v1.3.18 + SHA256 below,
|
||||
# MIT; §3.4/§4.15.1). Fetched source lands in the build tree, never in the
|
||||
# git tree. First slice adding it: URL + hash here, row in deps/README.md,
|
||||
# license text in THIRD_PARTY_NOTICES.md (per-issue notes).
|
||||
#
|
||||
# Build: ENet compiles as its own static lib with upstream flags (plain C).
|
||||
# The engine adapter (src/*.cpp) compiles with engine base flags
|
||||
# (no-exceptions/RTTI) but NOT hot flags: sessions do socket I/O and may
|
||||
# block in enet_host_service, so they never run on the frame path.
|
||||
# No exceptions cross the boundary: all entry points are noexcept and
|
||||
# return null/0/errno-style codes on failure.
|
||||
#
|
||||
# Target review surface: `enet` is the fetched static lib (no `add_subdirectory`
|
||||
# — ENet's CMakeLists installs targets unconditionally; we build only the
|
||||
# sources we need via an explicit source list from the fetched dir).
|
||||
include(FetchContent)
|
||||
|
||||
set(ENET_TAG "v1.3.18")
|
||||
set(ENET_SHA256 "28603c895f9ed24a846478180ee72c7376b39b4bb1287b73877e5eae7d96b0dd")
|
||||
|
||||
FetchContent_Declare(
|
||||
ENet
|
||||
URL "https://github.com/lsalzman/enet/archive/refs/tags/${ENET_TAG}.tar.gz"
|
||||
URL_HASH SHA256=${ENET_SHA256}
|
||||
)
|
||||
|
||||
FetchContent_GetProperties(ENet)
|
||||
if(NOT enet_POPULATED)
|
||||
FetchContent_Populate(ENet)
|
||||
endif()
|
||||
if(NOT EXISTS "${enet_SOURCE_DIR}/include/enet/enet.h")
|
||||
message(FATAL_ERROR "ENet source missing enet.h under ${enet_SOURCE_DIR}")
|
||||
endif()
|
||||
|
||||
set(ENET_SOURCES
|
||||
"${enet_SOURCE_DIR}/callbacks.c"
|
||||
"${enet_SOURCE_DIR}/compress.c"
|
||||
"${enet_SOURCE_DIR}/host.c"
|
||||
"${enet_SOURCE_DIR}/list.c"
|
||||
"${enet_SOURCE_DIR}/packet.c"
|
||||
"${enet_SOURCE_DIR}/peer.c"
|
||||
"${enet_SOURCE_DIR}/protocol.c"
|
||||
"${enet_SOURCE_DIR}/unix.c"
|
||||
"${enet_SOURCE_DIR}/win32.c"
|
||||
)
|
||||
set(ENET_EXISTING_SOURCES "")
|
||||
foreach(f IN LISTS ENET_SOURCES)
|
||||
if(EXISTS "${f}")
|
||||
list(APPEND ENET_EXISTING_SOURCES "${f}")
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
add_library(enet STATIC ${ENET_EXISTING_SOURCES})
|
||||
target_include_directories(enet PUBLIC
|
||||
$<BUILD_INTERFACE:${enet_SOURCE_DIR}/include>
|
||||
)
|
||||
if(NOT MSVC)
|
||||
target_compile_options(enet PRIVATE -w -Wno-error)
|
||||
else()
|
||||
target_compile_options(enet PRIVATE /W1)
|
||||
endif()
|
||||
set_target_properties(enet PROPERTIES
|
||||
POSITION_INDEPENDENT_CODE ON
|
||||
)
|
||||
|
||||
file(GLOB NET_SOURCES CONFIGURE_DEPENDS
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/src/*.cpp"
|
||||
)
|
||||
add_library(engine_core_net OBJECT ${NET_SOURCES})
|
||||
add_library(engine::net ALIAS engine_core_net)
|
||||
|
||||
target_include_directories(engine_core_net PUBLIC
|
||||
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
|
||||
)
|
||||
|
||||
# Building-side of ENGINE_CORE_API: the session class is compiled here.
|
||||
target_compile_definitions(engine_core_net PRIVATE ENGINE_CORE_BUILDING)
|
||||
|
||||
# ENet headers reach the adapter ONLY as SYSTEM includes (fetched C
|
||||
# kernel: never our -Wconversion -Werror). Link enet + core (export
|
||||
# macros) + ecs (LocalTransform layout for snapshot rows).
|
||||
target_include_directories(engine_core_net SYSTEM PRIVATE
|
||||
${enet_SOURCE_DIR}/include
|
||||
)
|
||||
target_link_libraries(engine_core_net PUBLIC
|
||||
enet
|
||||
engine_core_core
|
||||
engine_core_ecs
|
||||
)
|
||||
|
||||
engine_apply_base_flags(engine_core_net)
|
||||
@@ -0,0 +1,181 @@
|
||||
// net_session.hpp — P7-04: ENet server/client sessions, snapshot
|
||||
// replication with delta compression, reliable RPCs.
|
||||
//
|
||||
// One NetSession owns one ENetHost (server: bound address, up to 32
|
||||
// peers; client: unbound, one server peer). Wire channels:
|
||||
// channel 0 — snapshots, sequenced-unreliable (ENET_PACKET_FLAG_
|
||||
// UNSEQUENCED_SEND on unreliable packets; ENet drops stale
|
||||
// datagrams past the newest sequence number).
|
||||
// channel 1 — control + RPCs, reliable ordered.
|
||||
//
|
||||
// Snapshots: the server serializes N entity rows per tick. Each row is
|
||||
// (entity_id u64, changed_mask u16, changed float words). The changed
|
||||
// mask is a bitset over the 10 replicated words of EngineLocalTransform
|
||||
// (px,py,pz,qx,qy,qz,qw,sx,sy,sz); the first snapshot of an entity (or
|
||||
// after a NAK resync) sends all 10 words. The client keeps the last
|
||||
// applied value per entity and applies deltas idempotently; a NAK
|
||||
// (control message) from the client re-bases the server to a full
|
||||
// snapshot for the named entity. Sequence numbers (u32, wrapping)
|
||||
// detect loss for the acceptance gate; retransmit is by next-tick
|
||||
// full-row resend, not by ENet (channel 0 is unreliable by design).
|
||||
//
|
||||
// Simulated packet loss (acceptance: 10%) is injected on SEND in the
|
||||
// adapter (uniform drop via a cheap LCG, seedable) so the convergence
|
||||
// test needs no real network impairment.
|
||||
//
|
||||
// Threading: one session per thread; pump() drives enet_host_service.
|
||||
// No malloc/new on the frame path is an AGENTS.md invariant for NATIVE
|
||||
// FRAME code — sessions do socket I/O and may block in service(), so
|
||||
// they are host tooling like the bridge (base flags, exceptions/RTTI
|
||||
// on in tests). No exceptions cross the C ABI: entry points are
|
||||
// noexcept and return null/0/errno-style codes.
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
#include "engine/core/engine_core_export.hpp"
|
||||
|
||||
struct _ENetHost;
|
||||
struct _ENetPeer;
|
||||
|
||||
namespace engine::net {
|
||||
|
||||
// Replicated payload: EngineLocalTransform words 0..9
|
||||
// (px,py,pz,qx,qy,qz,qw,sx,sy,sz). pad[6] is never replicated.
|
||||
inline constexpr std::uint32_t kReplicatedWords = 10;
|
||||
inline constexpr std::uint32_t kMaxSnapshotRows = 4096;
|
||||
inline constexpr std::uint32_t kMaxRpcBytes = 1024;
|
||||
// Wire channels: 0 = snapshot (unsequenced), 1 = control/RPC (reliable).
|
||||
inline constexpr std::uint8_t kSnapshotChannel = 0;
|
||||
inline constexpr std::uint8_t kControlChannel = 1;
|
||||
|
||||
class ENGINE_CORE_API NetSession {
|
||||
public:
|
||||
NetSession() noexcept = default;
|
||||
NetSession(const NetSession&) = delete;
|
||||
auto operator=(const NetSession&) -> NetSession& = delete;
|
||||
~NetSession() noexcept;
|
||||
|
||||
// Server: bind port on loopback, allow up to 32 peers / 2 channels.
|
||||
// Returns false on ENet init or host-create failure.
|
||||
auto create_server(std::uint16_t port) noexcept -> bool;
|
||||
// Client: unbound host; connect to 127.0.0.1:port. Returns false on
|
||||
// init/host-create failure. Connection completes asynchronously:
|
||||
// poll until connected() is true.
|
||||
auto create_client(std::uint16_t port) noexcept -> bool;
|
||||
auto destroy() noexcept -> void;
|
||||
|
||||
[[nodiscard]] auto valid() const noexcept -> bool { return host_ != nullptr; }
|
||||
// True once at least one peer is connected (server: >=1 client;
|
||||
// client: server peer in CONNECTED state).
|
||||
[[nodiscard]] auto connected() const noexcept -> bool;
|
||||
// Pump ENet service with `timeout_ms` (0 = non-blocking). Returns
|
||||
// false on null host.
|
||||
auto poll(std::uint32_t timeout_ms) noexcept -> bool;
|
||||
|
||||
// --- Snapshot replication -------------------------------------------
|
||||
// Server side: serialize one tick for `count` rows (entities[i] with
|
||||
// 10-word float payload rows[i]) and broadcast on channel 0. Delta is
|
||||
// computed against the last SENT value per entity (server baseline);
|
||||
// `force_full` sends all words (first tick / resync). Returns rows
|
||||
// queued, or 0 on null host / bad args. Loss injection applies.
|
||||
auto snapshot_push(const std::uint64_t* entities, const float (*rows)[10],
|
||||
std::uint32_t count, bool force_full) noexcept
|
||||
-> std::uint32_t;
|
||||
// Client side: drain queued snapshots into out buffers (capacity rows).
|
||||
// Applies deltas onto the client's per-entity baseline so `rows[i]`
|
||||
// is the FULL 10-word value; unknown entities start from identity
|
||||
// (pos 0, quat identity, scale 1) before the delta applies. Returns
|
||||
// rows written. Emits a NAK for out-of-window sequences (server
|
||||
// re-bases to full on the next push).
|
||||
auto snapshot_pull(std::uint64_t* out_entities, float (*out_rows)[10],
|
||||
std::uint32_t capacity) noexcept -> std::uint32_t;
|
||||
|
||||
// --- RPCs ------------------------------------------------------------
|
||||
// Send a reliable RPC (id + payload bytes) on channel 1. Returns true
|
||||
// when queued. Loss injection does NOT apply (reliable by design).
|
||||
auto rpc_send(std::uint32_t id, const void* data,
|
||||
std::uint32_t size) noexcept -> bool;
|
||||
// Drain one queued RPC: returns bytes written to `out` (>0), 0 when
|
||||
// none pending, negative errno-style on bad args. `out_id` receives
|
||||
// the RPC id.
|
||||
auto rpc_poll(std::uint32_t* out_id, void* out,
|
||||
std::uint32_t capacity) noexcept -> std::int32_t;
|
||||
|
||||
// Uniform send-side drop probability [0,1] applied to snapshot sends
|
||||
// only (RPC/control stay reliable). Deterministic LCG, seedable.
|
||||
auto set_loss(float probability, std::uint32_t seed) noexcept -> void;
|
||||
|
||||
// Test introspection: snapshots sent/received, RPCs sent/received,
|
||||
// simulated drops.
|
||||
[[nodiscard]] auto stats_sent() const noexcept -> std::uint64_t {
|
||||
return sent_;
|
||||
}
|
||||
[[nodiscard]] auto stats_received() const noexcept -> std::uint64_t {
|
||||
return received_;
|
||||
}
|
||||
[[nodiscard]] auto stats_dropped() const noexcept -> std::uint64_t {
|
||||
return dropped_;
|
||||
}
|
||||
|
||||
private:
|
||||
_ENetHost* host_{nullptr};
|
||||
_ENetPeer* server_peer_{nullptr}; // client side only
|
||||
bool is_server_{false};
|
||||
|
||||
// Loss injection (snapshot channel only).
|
||||
float loss_p_{0.0F};
|
||||
std::uint32_t loss_state_{0x12345678U};
|
||||
|
||||
// Server baseline: last-sent value per entity (ring of 4096 slots,
|
||||
// direct-mapped by entity id bits — in-process test scale).
|
||||
struct BaseRow {
|
||||
std::uint64_t entity{0};
|
||||
float words[10]{};
|
||||
bool known{false};
|
||||
};
|
||||
static constexpr std::uint32_t kBaseSlots = 4096;
|
||||
BaseRow sent_base_[kBaseSlots]{};
|
||||
BaseRow recv_base_[kBaseSlots]{};
|
||||
|
||||
std::uint32_t send_seq_{0};
|
||||
std::uint32_t recv_seq_{0};
|
||||
// Set when a sequence gap is seen; poll() emits a NAK-all on the
|
||||
// reliable control channel so the server re-bases to a full send.
|
||||
bool need_nak_{false};
|
||||
// Server side: set by NAK-all; the next push forces full rows.
|
||||
bool resync_all_{false};
|
||||
|
||||
// Queued inbound snapshots (pump() fills; snapshot_pull drains).
|
||||
struct PendingRow {
|
||||
std::uint64_t entity{0};
|
||||
float words[10]{};
|
||||
};
|
||||
static constexpr std::uint32_t kPendingCap = 4096;
|
||||
PendingRow pending_[kPendingCap]{};
|
||||
std::uint32_t pending_count_{0};
|
||||
|
||||
// Queued inbound RPCs.
|
||||
struct PendingRpc {
|
||||
std::uint32_t id{0};
|
||||
std::uint32_t size{0};
|
||||
std::uint8_t data[kMaxRpcBytes]{};
|
||||
};
|
||||
static constexpr std::uint32_t kRpcCap = 256;
|
||||
PendingRpc rpcs_[kRpcCap]{};
|
||||
std::uint32_t rpc_count_{0};
|
||||
|
||||
std::uint64_t sent_{0};
|
||||
std::uint64_t received_{0};
|
||||
std::uint64_t dropped_{0};
|
||||
|
||||
auto should_drop() noexcept -> bool;
|
||||
static auto base_slot(std::uint64_t entity) noexcept -> std::uint32_t;
|
||||
// ENet event pump: routes channel-0 packets to pending_, channel-1
|
||||
// to rpcs_/control. Called by poll().
|
||||
auto on_receive(std::uint8_t channel, const std::uint8_t* data,
|
||||
std::size_t len) noexcept -> void;
|
||||
};
|
||||
|
||||
} // namespace engine::net
|
||||
@@ -0,0 +1,493 @@
|
||||
// net_session.cpp — P7-04 implementation; see header for the protocol.
|
||||
//
|
||||
// Wire formats (little-endian, host order on x86/ARM CI — the acceptance
|
||||
// peers are in-process on one machine; no cross-endian support claimed):
|
||||
//
|
||||
// Snapshot packet (channel 0, unreliable sequenced via ENet):
|
||||
// u32 seq | u16 row_count | rows...
|
||||
// row: u64 entity | u16 mask | float words[popcount(mask)]
|
||||
// (words are the changed subset of the 10 replicated LocalTransform
|
||||
// words, in word-index order)
|
||||
//
|
||||
// Control packet (channel 1, reliable): u8 kind | payload...
|
||||
// kind 0 NAK: u64 entity (server re-bases that entity to full send)
|
||||
// kind 1 RPC: u32 id | u32 size | bytes[size]
|
||||
//
|
||||
// ENet already sequences channel-0 packets (unsequenced-send flag means
|
||||
// "drop stale", not "unordered"): the adapter-level u32 seq detects gaps
|
||||
// for stats; recovery is next-tick resend (snapshot_pull applies whatever
|
||||
// arrives; the convergence test resends every tick).
|
||||
#include "engine/net/net_session.hpp"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include "enet/enet.h"
|
||||
|
||||
namespace engine::net {
|
||||
|
||||
namespace {
|
||||
|
||||
// ENet one-time init (refcounted: N sessions share one process).
|
||||
int g_enet_refs = 0;
|
||||
|
||||
auto EnetUp() noexcept -> bool {
|
||||
if (g_enet_refs == 0) {
|
||||
if (enet_initialize() != 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
++g_enet_refs;
|
||||
return true;
|
||||
}
|
||||
|
||||
auto EnetDown() noexcept -> void {
|
||||
if (g_enet_refs > 0) {
|
||||
--g_enet_refs;
|
||||
if (g_enet_refs == 0) {
|
||||
enet_deinitialize();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
auto FloatEq(float a, float b) noexcept -> bool {
|
||||
// Bit-exact compare: the convergence test writes identical floats, and
|
||||
// bit-exactness keeps delta masks stable (no epsilon flapping).
|
||||
std::uint32_t ua = 0;
|
||||
std::uint32_t ub = 0;
|
||||
std::memcpy(&ua, &a, sizeof(ua));
|
||||
std::memcpy(&ub, &b, sizeof(ub));
|
||||
return ua == ub;
|
||||
}
|
||||
|
||||
auto PopCount(std::uint16_t m) noexcept -> std::uint32_t {
|
||||
std::uint32_t n = 0;
|
||||
while (m != 0U) {
|
||||
n += (m & 1U);
|
||||
m = static_cast<std::uint16_t>(m >> 1);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
NetSession::~NetSession() noexcept { destroy(); }
|
||||
|
||||
auto NetSession::create_server(std::uint16_t port) noexcept -> bool {
|
||||
if (host_ != nullptr) {
|
||||
return false;
|
||||
}
|
||||
if (!EnetUp()) {
|
||||
return false;
|
||||
}
|
||||
ENetAddress addr{};
|
||||
addr.host = ENET_HOST_ANY;
|
||||
addr.port = port;
|
||||
host_ = enet_host_create(&addr, 32, 2, 0, 0);
|
||||
if (host_ == nullptr) {
|
||||
EnetDown();
|
||||
return false;
|
||||
}
|
||||
is_server_ = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
auto NetSession::create_client(std::uint16_t port) noexcept -> bool {
|
||||
if (host_ != nullptr) {
|
||||
return false;
|
||||
}
|
||||
if (!EnetUp()) {
|
||||
return false;
|
||||
}
|
||||
host_ = enet_host_create(nullptr, 1, 2, 0, 0);
|
||||
if (host_ == nullptr) {
|
||||
EnetDown();
|
||||
return false;
|
||||
}
|
||||
ENetAddress addr{};
|
||||
// Loopback: the acceptance peers are in-process on one machine.
|
||||
enet_address_set_host(&addr, "127.0.0.1");
|
||||
addr.port = port;
|
||||
server_peer_ = enet_host_connect(host_, &addr, 2, 0);
|
||||
if (server_peer_ == nullptr) {
|
||||
enet_host_destroy(host_);
|
||||
host_ = nullptr;
|
||||
EnetDown();
|
||||
return false;
|
||||
}
|
||||
is_server_ = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
auto NetSession::destroy() noexcept -> void {
|
||||
if (host_ == nullptr) {
|
||||
return;
|
||||
}
|
||||
if (!is_server_ && server_peer_ != nullptr) {
|
||||
enet_peer_disconnect(server_peer_, 0);
|
||||
// Best-effort flush of the disconnect (non-blocking: 3 polls).
|
||||
ENetEvent ev{};
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
enet_host_service(host_, &ev, 0);
|
||||
}
|
||||
server_peer_ = nullptr;
|
||||
}
|
||||
enet_host_destroy(host_);
|
||||
host_ = nullptr;
|
||||
pending_count_ = 0;
|
||||
rpc_count_ = 0;
|
||||
EnetDown();
|
||||
}
|
||||
|
||||
auto NetSession::connected() const noexcept -> bool {
|
||||
if (host_ == nullptr) {
|
||||
return false;
|
||||
}
|
||||
if (is_server_) {
|
||||
return host_->connectedPeers > 0;
|
||||
}
|
||||
return server_peer_ != nullptr &&
|
||||
server_peer_->state == ENET_PEER_STATE_CONNECTED;
|
||||
}
|
||||
|
||||
auto NetSession::poll(std::uint32_t timeout_ms) noexcept -> bool {
|
||||
if (host_ == nullptr) {
|
||||
return false;
|
||||
}
|
||||
ENetEvent ev{};
|
||||
// Drain all pending events; a single service() call returns one.
|
||||
int rc = enet_host_service(host_, &ev, timeout_ms);
|
||||
while (rc > 0) {
|
||||
switch (ev.type) {
|
||||
case ENET_EVENT_TYPE_RECEIVE:
|
||||
on_receive(ev.channelID, ev.packet->data, ev.packet->dataLength);
|
||||
enet_packet_destroy(ev.packet);
|
||||
break;
|
||||
case ENET_EVENT_TYPE_DISCONNECT:
|
||||
if (!is_server_ && ev.peer == server_peer_) {
|
||||
server_peer_ = nullptr;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
rc = enet_host_service(host_, &ev, 0);
|
||||
}
|
||||
// Gap seen on the snapshot channel: emit one NAK-all on the reliable
|
||||
// control channel (idempotent; the server re-bases once per push).
|
||||
if (need_nak_) {
|
||||
need_nak_ = false;
|
||||
std::uint8_t nak[1] = {0}; // kind 0, no entity = all
|
||||
ENetPacket* pkt = enet_packet_create(nak, 1, ENET_PACKET_FLAG_RELIABLE);
|
||||
if (pkt != nullptr) {
|
||||
if (is_server_) {
|
||||
enet_host_broadcast(host_, kControlChannel, pkt);
|
||||
} else if (server_peer_ != nullptr) {
|
||||
enet_peer_send(server_peer_, kControlChannel, pkt);
|
||||
} else {
|
||||
enet_packet_destroy(pkt);
|
||||
}
|
||||
enet_host_flush(host_);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
auto NetSession::base_slot(std::uint64_t entity) noexcept -> std::uint32_t {
|
||||
// Direct-map: low 12 bits. In-process test scale (100 entities);
|
||||
// collisions re-base to full (known=false on entity mismatch).
|
||||
return static_cast<std::uint32_t>(entity & (kBaseSlots - 1));
|
||||
}
|
||||
|
||||
auto NetSession::should_drop() noexcept -> bool {
|
||||
if (loss_p_ <= 0.0F) {
|
||||
return false;
|
||||
}
|
||||
// Cheap LCG: state = state * a + c (glibc constants), take high bits.
|
||||
loss_state_ = loss_state_ * 1103515245U + 12345U;
|
||||
const auto roll = (loss_state_ >> 8) & 0xFFFFFFU;
|
||||
return (static_cast<float>(roll) / 16777216.0F) < loss_p_;
|
||||
}
|
||||
|
||||
auto NetSession::snapshot_push(const std::uint64_t* entities,
|
||||
const float (*rows)[10], std::uint32_t count,
|
||||
bool force_full) noexcept -> std::uint32_t {
|
||||
if (host_ == nullptr || (entities == nullptr && count > 0) ||
|
||||
(rows == nullptr && count > 0) || count > kMaxSnapshotRows) {
|
||||
return 0;
|
||||
}
|
||||
// Encode: seq + row_count + rows. Worst case 6 + count*(8+2+40).
|
||||
// Stack-bounded: snapshots are tick-sized (acceptance: 100 rows).
|
||||
constexpr std::size_t kBufSize = 6 + 256U * (8U + 2U + 40U);
|
||||
std::uint8_t buf[kBufSize]{};
|
||||
const std::uint32_t seq = send_seq_++;
|
||||
std::memcpy(buf, &seq, 4);
|
||||
const auto n16 = static_cast<std::uint16_t>(count);
|
||||
std::memcpy(buf + 4, &n16, 2);
|
||||
std::size_t off = 6;
|
||||
std::uint32_t queued = 0;
|
||||
for (std::uint32_t i = 0; i < count; ++i) {
|
||||
if (off + 8 + 2 + 40 > kBufSize) {
|
||||
break;
|
||||
}
|
||||
const std::uint64_t ent = entities[i];
|
||||
BaseRow& base = sent_base_[base_slot(ent)];
|
||||
if (base.entity != ent || !base.known) {
|
||||
base.entity = ent;
|
||||
base.known = false; // full send below re-establishes
|
||||
}
|
||||
std::uint16_t mask = 0;
|
||||
const bool full_row = force_full || resync_all_ || !base.known;
|
||||
if (full_row) {
|
||||
mask = 0x3FFU; // all 10 words
|
||||
} else {
|
||||
for (std::uint32_t w = 0; w < kReplicatedWords; ++w) {
|
||||
if (!FloatEq(rows[i][w], base.words[w])) {
|
||||
mask |= static_cast<std::uint16_t>(1U << w);
|
||||
}
|
||||
}
|
||||
if (mask == 0) {
|
||||
continue; // unchanged: skip the row entirely (delta win)
|
||||
}
|
||||
}
|
||||
std::memcpy(buf + off, &ent, 8);
|
||||
off += 8;
|
||||
std::memcpy(buf + off, &mask, 2);
|
||||
off += 2;
|
||||
for (std::uint32_t w = 0; w < kReplicatedWords; ++w) {
|
||||
if ((mask & (1U << w)) != 0U) {
|
||||
std::memcpy(buf + off, &rows[i][w], 4);
|
||||
off += 4;
|
||||
base.words[w] = rows[i][w];
|
||||
}
|
||||
}
|
||||
base.entity = ent;
|
||||
base.known = true;
|
||||
++queued;
|
||||
}
|
||||
// Rewrite the row count to the rows actually encoded.
|
||||
const auto q16 = static_cast<std::uint16_t>(queued);
|
||||
std::memcpy(buf + 4, &q16, 2);
|
||||
|
||||
if (should_drop()) {
|
||||
++dropped_;
|
||||
return queued; // counted as sent-side work, packet never hit ENet
|
||||
}
|
||||
// A NAK-all resync re-bases every entity below (consumed here so the
|
||||
// request affects exactly one push).
|
||||
if (resync_all_) {
|
||||
resync_all_ = false;
|
||||
}
|
||||
ENetPacket* pkt = enet_packet_create(buf, off, ENET_PACKET_FLAG_UNSEQUENCED);
|
||||
if (pkt == nullptr) {
|
||||
return 0;
|
||||
}
|
||||
if (is_server_) {
|
||||
enet_host_broadcast(host_, kSnapshotChannel, pkt);
|
||||
} else if (server_peer_ != nullptr) {
|
||||
enet_peer_send(server_peer_, kSnapshotChannel, pkt);
|
||||
} else {
|
||||
enet_packet_destroy(pkt);
|
||||
return 0;
|
||||
}
|
||||
enet_host_flush(host_);
|
||||
sent_ += queued;
|
||||
return queued;
|
||||
}
|
||||
|
||||
auto NetSession::snapshot_pull(std::uint64_t* out_entities,
|
||||
float (*out_rows)[10],
|
||||
std::uint32_t capacity) noexcept
|
||||
-> std::uint32_t {
|
||||
if ((out_entities == nullptr && capacity > 0) ||
|
||||
(out_rows == nullptr && capacity > 0)) {
|
||||
return 0;
|
||||
}
|
||||
std::uint32_t n = pending_count_ < capacity ? pending_count_ : capacity;
|
||||
for (std::uint32_t i = 0; i < n; ++i) {
|
||||
out_entities[i] = pending_[i].entity;
|
||||
for (std::uint32_t w = 0; w < kReplicatedWords; ++w) {
|
||||
out_rows[i][w] = pending_[i].words[w];
|
||||
}
|
||||
}
|
||||
// Compact the remainder (packets may arrive faster than ticks drain).
|
||||
std::uint32_t rest = pending_count_ - n;
|
||||
for (std::uint32_t i = 0; i < rest && i < kPendingCap; ++i) {
|
||||
pending_[i] = pending_[n + i];
|
||||
}
|
||||
pending_count_ = rest;
|
||||
received_ += n;
|
||||
return n;
|
||||
}
|
||||
|
||||
auto NetSession::rpc_send(std::uint32_t id, const void* data,
|
||||
std::uint32_t size) noexcept -> bool {
|
||||
if (host_ == nullptr || (data == nullptr && size > 0) ||
|
||||
size > kMaxRpcBytes) {
|
||||
return false;
|
||||
}
|
||||
std::uint8_t buf[1 + 4 + 4 + kMaxRpcBytes]{};
|
||||
buf[0] = 1; // kind = RPC
|
||||
std::memcpy(buf + 1, &id, 4);
|
||||
std::memcpy(buf + 5, &size, 4);
|
||||
if (size > 0) {
|
||||
std::memcpy(buf + 9, data, size);
|
||||
}
|
||||
ENetPacket* pkt =
|
||||
enet_packet_create(buf, 9 + size, ENET_PACKET_FLAG_RELIABLE);
|
||||
if (pkt == nullptr) {
|
||||
return false;
|
||||
}
|
||||
if (is_server_) {
|
||||
enet_host_broadcast(host_, kControlChannel, pkt);
|
||||
} else if (server_peer_ != nullptr) {
|
||||
enet_peer_send(server_peer_, kControlChannel, pkt);
|
||||
} else {
|
||||
enet_packet_destroy(pkt);
|
||||
return false;
|
||||
}
|
||||
enet_host_flush(host_);
|
||||
++sent_;
|
||||
return true;
|
||||
}
|
||||
|
||||
auto NetSession::rpc_poll(std::uint32_t* out_id, void* out,
|
||||
std::uint32_t capacity) noexcept -> std::int32_t {
|
||||
if (out_id == nullptr || (out == nullptr && capacity > 0)) {
|
||||
return -1;
|
||||
}
|
||||
if (rpc_count_ == 0) {
|
||||
return 0;
|
||||
}
|
||||
const PendingRpc& r = rpcs_[0];
|
||||
if (r.size > capacity) {
|
||||
return -2; // caller buffer too small; entry kept
|
||||
}
|
||||
*out_id = r.id;
|
||||
if (r.size > 0) {
|
||||
std::memcpy(out, r.data, r.size);
|
||||
}
|
||||
for (std::uint32_t i = 1; i < rpc_count_; ++i) {
|
||||
rpcs_[i - 1] = rpcs_[i];
|
||||
}
|
||||
--rpc_count_;
|
||||
++received_;
|
||||
return static_cast<std::int32_t>(r.size);
|
||||
}
|
||||
|
||||
auto NetSession::set_loss(float probability, std::uint32_t seed) noexcept
|
||||
-> void {
|
||||
loss_p_ = probability < 0.0F ? 0.0F
|
||||
: probability > 1.0F ? 1.0F
|
||||
: probability;
|
||||
loss_state_ = seed;
|
||||
}
|
||||
|
||||
auto NetSession::on_receive(std::uint8_t channel, const std::uint8_t* data,
|
||||
std::size_t len) noexcept -> void {
|
||||
if (data == nullptr) {
|
||||
return;
|
||||
}
|
||||
if (channel == kSnapshotChannel) {
|
||||
if (len < 6) {
|
||||
return;
|
||||
}
|
||||
std::uint32_t seq = 0;
|
||||
std::uint16_t rows = 0;
|
||||
std::memcpy(&seq, data, 4);
|
||||
std::memcpy(&rows, data + 4, 2);
|
||||
// Stale/duplicate guard: ENet unsequenced delivery already drops
|
||||
// older datagrams; the adapter seq is a gap detector. On a gap the
|
||||
// client may hold deltas for a baseline it never received, so flag
|
||||
// a NAK-all: the server's next push re-bases to full rows and the
|
||||
// streams reconverge (polled out at the end of poll()).
|
||||
if (seq != recv_seq_ + 1 && recv_seq_ != 0) {
|
||||
need_nak_ = true;
|
||||
}
|
||||
recv_seq_ = seq;
|
||||
std::size_t off = 6;
|
||||
for (std::uint16_t r = 0; r < rows; ++r) {
|
||||
if (off + 10 > len || pending_count_ >= kPendingCap) {
|
||||
break;
|
||||
}
|
||||
std::uint64_t ent = 0;
|
||||
std::uint16_t mask = 0;
|
||||
std::memcpy(&ent, data + off, 8);
|
||||
off += 8;
|
||||
std::memcpy(&mask, data + off, 2);
|
||||
off += 2;
|
||||
const std::uint32_t words = PopCount(mask);
|
||||
if (off + words * 4U > len) {
|
||||
break; // truncated: drop the tail of this packet
|
||||
}
|
||||
BaseRow& base = recv_base_[base_slot(ent)];
|
||||
if (base.entity != ent || !base.known) {
|
||||
// New entity: identity baseline (pos 0, quat identity, scale 1).
|
||||
base.entity = ent;
|
||||
for (std::uint32_t w = 0; w < kReplicatedWords; ++w) {
|
||||
base.words[w] = 0.0F;
|
||||
}
|
||||
base.words[6] = 1.0F; // qw
|
||||
base.words[7] = 1.0F; // sx
|
||||
base.words[8] = 1.0F; // sy
|
||||
base.words[9] = 1.0F; // sz
|
||||
base.known = true;
|
||||
}
|
||||
for (std::uint32_t w = 0; w < kReplicatedWords; ++w) {
|
||||
if ((mask & (1U << w)) != 0U) {
|
||||
std::memcpy(&base.words[w], data + off, 4);
|
||||
off += 4;
|
||||
}
|
||||
}
|
||||
PendingRow& p = pending_[pending_count_++];
|
||||
p.entity = ent;
|
||||
for (std::uint32_t w = 0; w < kReplicatedWords; ++w) {
|
||||
p.words[w] = base.words[w];
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (channel == kControlChannel) {
|
||||
if (len < 1) {
|
||||
return;
|
||||
}
|
||||
const std::uint8_t kind = data[0];
|
||||
if (kind == 0) { // NAK: len 1 = all entities, len 9 = one entity
|
||||
if (len == 1) {
|
||||
resync_all_ = true;
|
||||
return;
|
||||
}
|
||||
if (len < 9) {
|
||||
return;
|
||||
}
|
||||
std::uint64_t ent = 0;
|
||||
std::memcpy(&ent, data + 1, 8);
|
||||
BaseRow& base = sent_base_[base_slot(ent)];
|
||||
if (base.entity == ent) {
|
||||
base.known = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (kind == 1) { // RPC
|
||||
if (len < 9 || rpc_count_ >= kRpcCap) {
|
||||
return;
|
||||
}
|
||||
std::uint32_t id = 0;
|
||||
std::uint32_t size = 0;
|
||||
std::memcpy(&id, data + 1, 4);
|
||||
std::memcpy(&size, data + 5, 4);
|
||||
if (size > kMaxRpcBytes || 9 + size > len) {
|
||||
return;
|
||||
}
|
||||
PendingRpc& r = rpcs_[rpc_count_++];
|
||||
r.id = id;
|
||||
r.size = size;
|
||||
if (size > 0) {
|
||||
std::memcpy(r.data, data + 9, size);
|
||||
}
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace engine::net
|
||||
@@ -1074,3 +1074,16 @@ if(WIN32)
|
||||
COMMAND_EXPAND_LISTS)
|
||||
endforeach()
|
||||
endif()
|
||||
|
||||
# P7-04: ENet networking acceptance. Two in-process peers (loopback)
|
||||
# converge on 100 replicated transforms within 10 ticks at 10%
|
||||
# simulated loss; reliable RPCs both directions; delta-compression
|
||||
# gates (0 rows on no-change, 1 row on single-word change).
|
||||
add_executable(engine_net_replication test_net_replication.cpp)
|
||||
|
||||
target_link_libraries(engine_net_replication PRIVATE engine_core)
|
||||
|
||||
engine_apply_base_flags(engine_net_replication)
|
||||
|
||||
add_test(NAME engine_net_replication COMMAND engine_net_replication)
|
||||
set_tests_properties(engine_net_replication PROPERTIES TIMEOUT 300)
|
||||
@@ -224,7 +224,7 @@ auto main() -> int {
|
||||
failures += check(offsetof(EcsChunkView, world) == 8, "abi: EcsChunkView.world @ 8");
|
||||
failures += check(offsetof(EcsChunkView, entities) == 16, "abi: EcsChunkView.entities @ 16");
|
||||
failures += check(offsetof(EcsChunkView, user_data) == 40, "abi: EcsChunkView.user_data @ 40");
|
||||
failures += check(sizeof(EngineApiTable) == 712, "abi: EngineApiTable 712B (ABI 4.10 hierarchy tail)");
|
||||
failures += check(sizeof(EngineApiTable) == 792, "abi: EngineApiTable 792B (ABI 4.11 networking tail)");
|
||||
failures += check(sizeof(EngineInputState) == 1668, "abi: EngineInputState 1668B");
|
||||
failures += check(offsetof(EngineInputState, mouse_x) == 1536, "abi: EngineInputState.mouse_x @ 1536");
|
||||
failures += check(offsetof(EngineInputState, mouse_buttons) == 1552, "abi: EngineInputState.mouse_buttons @ 1552");
|
||||
@@ -329,6 +329,35 @@ auto main() -> int {
|
||||
"abi: editor_destroy_entity @ 672 (4.10 tail)");
|
||||
failures += check(offsetof(EngineApiTable, editor_snapshot_restore_subtree) == 704,
|
||||
"abi: editor_snapshot_restore_subtree @ 704 (4.10 tail end)");
|
||||
// P7-04 networking tail (ABI 4.11): 10 appended entries after the 4.10
|
||||
// tail (server/client sessions, snapshots, RPCs, loss). All earlier
|
||||
// entries frozen.
|
||||
failures += check(api->net_create_server != nullptr, "abi: net_create_server set");
|
||||
failures += check(api->net_create_client != nullptr, "abi: net_create_client set");
|
||||
failures += check(api->net_destroy != nullptr, "abi: net_destroy set");
|
||||
failures += check(api->net_snapshot_push != nullptr, "abi: net_snapshot_push set");
|
||||
failures += check(api->net_snapshot_pull != nullptr, "abi: net_snapshot_pull set");
|
||||
failures += check(api->net_rpc_send != nullptr, "abi: net_rpc_send set");
|
||||
failures += check(api->net_rpc_poll != nullptr, "abi: net_rpc_poll set");
|
||||
failures += check(sizeof(EngineNetTransformRow) == 48, "abi: EngineNetTransformRow 48B");
|
||||
failures += check(offsetof(EngineNetTransformRow, words) == 8, "abi: EngineNetTransformRow.words @ 8");
|
||||
failures += check(offsetof(EngineApiTable, net_create_server) == 712,
|
||||
"abi: net_create_server @ 712 (4.11 tail)");
|
||||
failures += check(offsetof(EngineApiTable, net_set_loss) == 784,
|
||||
"abi: net_set_loss @ 784 (4.11 tail end)");
|
||||
failures += check(api->net_create_server(0) == nullptr, "abi: net server port 0 -> null");
|
||||
failures += check(api->net_create_client(0) == nullptr, "abi: net client port 0 -> null");
|
||||
failures += check(api->net_connected(nullptr) == 0, "abi: net connected null -> 0");
|
||||
failures += check(api->net_poll(nullptr, 0) != 0, "abi: net poll null rejected");
|
||||
failures += check(api->net_snapshot_push(nullptr, nullptr, 0, 0) == 0,
|
||||
"abi: net push null -> 0");
|
||||
failures += check(api->net_snapshot_pull(nullptr, nullptr, 0) == 0,
|
||||
"abi: net pull null -> 0");
|
||||
failures += check(api->net_rpc_send(nullptr, 1, nullptr, 0) != 0,
|
||||
"abi: net rpc_send null rejected");
|
||||
failures += check(api->net_rpc_poll(nullptr, nullptr, nullptr, 0) != 0,
|
||||
"abi: net rpc_poll null rejected");
|
||||
api->net_destroy(nullptr); // idempotent on NULL
|
||||
failures += check(offsetof(EngineApiTable, editor_create) == 168,
|
||||
"abi: editor_create @ 168 (M5-02 tail)");
|
||||
failures += check(api->nav_create != nullptr, "abi: nav_create set");
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
// test_net_replication.cpp — P7-04 acceptance: two in-process peers
|
||||
// converge on 100 replicated transforms within 10 ticks at 10%
|
||||
// simulated packet loss.
|
||||
//
|
||||
// Setup: server + client NetSessions over loopback (ports picked from a
|
||||
// fixed ladder with bind-retry). Both sides hold 100 entity rows
|
||||
// (entity ids 1..100, LocalTransform 10-word payloads). Each tick:
|
||||
// 1. server mutates every transform (tick-indexed drift),
|
||||
// 2. server snapshot_push (force_full on tick 0),
|
||||
// 3. both sides poll (0ms + 1ms settle) to move ENet queues,
|
||||
// 4. client snapshot_pull applies to its mirror rows.
|
||||
// After <= 10 ticks every client row must equal the server row
|
||||
// bit-exact. RPC gate: 4 reliable sends (both directions) all arrive
|
||||
// intact DESPITE 10% snapshot loss (loss applies to snapshots only).
|
||||
// Delta gate: a no-change push queues zero rows (mask-all-clear skip).
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
|
||||
#include "engine/net/net_session.hpp"
|
||||
|
||||
namespace {
|
||||
|
||||
auto check(bool ok, const char* name) -> int {
|
||||
std::printf("[%s] %s\n", ok ? "PASS" : "FAIL", name);
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
|
||||
constexpr std::uint32_t kEntities = 100;
|
||||
constexpr std::uint32_t kTicks = 10;
|
||||
|
||||
auto MakeRow(std::uint32_t ent, std::uint32_t tick, float out[10]) -> void {
|
||||
// Deterministic drift: pos walks with tick, quat stays identity-ish,
|
||||
// scale fixed. Distinct per entity so row mix-ups fail the check.
|
||||
const float f = static_cast<float>(ent);
|
||||
const float t = static_cast<float>(tick);
|
||||
out[0] = f * 1.0F + t * 0.25F;
|
||||
out[1] = f * 2.0F - t * 0.125F;
|
||||
out[2] = f * 0.5F + t * 0.5F;
|
||||
out[3] = 0.0F;
|
||||
out[4] = 0.0F;
|
||||
out[5] = 0.0F;
|
||||
out[6] = 1.0F;
|
||||
out[7] = 1.0F;
|
||||
out[8] = 1.0F;
|
||||
out[9] = 1.0F;
|
||||
}
|
||||
|
||||
auto PumpBoth(engine::net::NetSession& a, engine::net::NetSession& b)
|
||||
-> void {
|
||||
// Move ENet queues: service both ends, then a 1ms settle for the
|
||||
// loopback round-trip (localhost delivery is same-tick, but the
|
||||
// settle keeps loaded-CI flakes at zero).
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
a.poll(0);
|
||||
b.poll(0);
|
||||
}
|
||||
a.poll(1);
|
||||
b.poll(1);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
auto main() -> int {
|
||||
setvbuf(stdout, nullptr, _IONBF, 0);
|
||||
int failures = 0;
|
||||
|
||||
engine::net::NetSession server;
|
||||
engine::net::NetSession client;
|
||||
std::uint16_t port = 0;
|
||||
bool up = false;
|
||||
for (std::uint16_t p = 51234; p < 51244; ++p) {
|
||||
if (!server.create_server(p)) {
|
||||
continue;
|
||||
}
|
||||
if (!client.create_client(p)) {
|
||||
server.destroy();
|
||||
continue;
|
||||
}
|
||||
port = p;
|
||||
up = true;
|
||||
break;
|
||||
}
|
||||
failures += check(up, "net: server+client sessions created");
|
||||
if (!up) {
|
||||
std::printf("test_net_replication: %d FAILED (no bind)\n", failures);
|
||||
return 1;
|
||||
}
|
||||
std::printf("[PASS] net: bound loopback port %u\n", port);
|
||||
|
||||
// Connect handshake (client CONNECTED, server sees 1 peer).
|
||||
bool linked = false;
|
||||
for (int i = 0; i < 200 && !linked; ++i) {
|
||||
PumpBoth(server, client);
|
||||
linked = server.connected() && client.connected();
|
||||
}
|
||||
failures += check(linked, "net: client connected, server sees peer");
|
||||
|
||||
// 10% simulated loss on snapshot sends (both directions). The two
|
||||
// seeds below were verified to actually drop packets on the tick-0
|
||||
// full snapshot (see drops line); they pin the loss draw, not the
|
||||
// tick count — convergence must hold for ANY seed.
|
||||
server.set_loss(0.10F, 4U);
|
||||
client.set_loss(0.10F, 4U);
|
||||
|
||||
// Server-authoritative rows + client mirror.
|
||||
std::uint64_t entities[kEntities]{};
|
||||
float server_rows[kEntities][10]{};
|
||||
float client_rows[kEntities][10]{};
|
||||
for (std::uint32_t i = 0; i < kEntities; ++i) {
|
||||
entities[i] = static_cast<std::uint64_t>(i + 1);
|
||||
MakeRow(i + 1, 0, server_rows[i]);
|
||||
// Client starts from identity baseline (all zeros except qw/s=1).
|
||||
for (int w = 0; w < 10; ++w) {
|
||||
client_rows[i][w] = 0.0F;
|
||||
}
|
||||
client_rows[i][6] = 1.0F;
|
||||
client_rows[i][7] = 1.0F;
|
||||
client_rows[i][8] = 1.0F;
|
||||
client_rows[i][9] = 1.0F;
|
||||
}
|
||||
|
||||
std::uint64_t pull_ent[kEntities]{};
|
||||
float pull_row[kEntities][10]{};
|
||||
|
||||
bool converged = false;
|
||||
std::uint32_t converged_tick = 0;
|
||||
for (std::uint32_t tick = 0; tick < kTicks; ++tick) {
|
||||
for (std::uint32_t i = 0; i < kEntities; ++i) {
|
||||
MakeRow(i + 1, tick, server_rows[i]);
|
||||
}
|
||||
server.snapshot_push(entities, server_rows, kEntities, tick == 0);
|
||||
PumpBoth(server, client);
|
||||
// Drain everything queued (loss + batching may stack packets).
|
||||
for (int d = 0; d < 8; ++d) {
|
||||
std::uint32_t n =
|
||||
client.snapshot_pull(pull_ent, pull_row, kEntities);
|
||||
for (std::uint32_t i = 0; i < n; ++i) {
|
||||
const std::uint32_t idx =
|
||||
static_cast<std::uint32_t>(pull_ent[i] - 1);
|
||||
if (idx < kEntities) {
|
||||
for (int w = 0; w < 10; ++w) {
|
||||
client_rows[idx][w] = pull_row[i][w];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (n == 0) {
|
||||
break;
|
||||
}
|
||||
PumpBoth(server, client);
|
||||
}
|
||||
bool match = true;
|
||||
for (std::uint32_t i = 0; i < kEntities && match; ++i) {
|
||||
for (int w = 0; w < 10; ++w) {
|
||||
if (server_rows[i][w] != client_rows[i][w]) {
|
||||
match = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (match) {
|
||||
converged = true;
|
||||
converged_tick = tick;
|
||||
break;
|
||||
}
|
||||
}
|
||||
failures += check(converged, "net: 100 transforms converge within 10 ticks @10% loss");
|
||||
if (converged) {
|
||||
std::printf("[PASS] net: converged at tick %u (drops: server %llu client %llu)\n",
|
||||
converged_tick,
|
||||
(unsigned long long)server.stats_dropped(),
|
||||
(unsigned long long)client.stats_dropped());
|
||||
}
|
||||
|
||||
// Delta gate: no-change push queues zero rows (bandwidth win), and a
|
||||
// single-word change sends exactly one row.
|
||||
float same[kEntities][10]{};
|
||||
for (std::uint32_t i = 0; i < kEntities; ++i) {
|
||||
for (int w = 0; w < 10; ++w) {
|
||||
same[i][w] = server_rows[i][w];
|
||||
}
|
||||
}
|
||||
// Drain first (stale packets would pollute the count via stats only;
|
||||
// snapshot_push returns queued count directly — no ENet read needed).
|
||||
std::uint32_t q0 = server.snapshot_push(entities, same, kEntities, false);
|
||||
failures += check(q0 == 0, "net: unchanged snapshot pushes 0 rows");
|
||||
same[42][0] += 1.0F;
|
||||
std::uint32_t q1 = server.snapshot_push(entities, same, kEntities, false);
|
||||
failures += check(q1 == 1, "net: single-word change pushes 1 row");
|
||||
// Restore server baseline for the row we dirtied (keep later checks exact).
|
||||
server.snapshot_push(entities, server_rows, kEntities, true);
|
||||
PumpBoth(server, client);
|
||||
// Drain the wire so the RPC gate below sees a clean queue.
|
||||
for (int d = 0; d < 8; ++d) {
|
||||
if (client.snapshot_pull(pull_ent, pull_row, kEntities) == 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// RPC gate (reliable despite snapshot loss): both directions, payload
|
||||
// integrity, ordering (FIFO per sender).
|
||||
const char* pings[2] = {"hello-peer", "second-msg"};
|
||||
bool rpc_ok = true;
|
||||
for (int i = 0; i < 2; ++i) {
|
||||
const char* m = pings[i];
|
||||
std::size_t len = 0;
|
||||
while (m[len] != '\0') {
|
||||
++len;
|
||||
}
|
||||
if (!server.rpc_send(1000U + (std::uint32_t)i, m,
|
||||
(std::uint32_t)len + 1)) {
|
||||
rpc_ok = false;
|
||||
}
|
||||
}
|
||||
const char reply[] = "ack-from-client";
|
||||
if (!client.rpc_send(7, reply, sizeof(reply))) {
|
||||
rpc_ok = false;
|
||||
}
|
||||
// Collect: client should get 2, server should get 1.
|
||||
char rbuf[1100]{};
|
||||
std::uint32_t rid = 0;
|
||||
int got_client = 0;
|
||||
int got_server = 0;
|
||||
for (int spin = 0; spin < 200 && (got_client < 2 || got_server < 1);
|
||||
++spin) {
|
||||
PumpBoth(server, client);
|
||||
while (true) {
|
||||
std::int32_t n = client.rpc_poll(&rid, rbuf, sizeof(rbuf));
|
||||
if (n <= 0) {
|
||||
break;
|
||||
}
|
||||
++got_client;
|
||||
if (got_client == 1 && (rid != 1000U || rbuf[0] != 'h')) {
|
||||
rpc_ok = false;
|
||||
}
|
||||
if (got_client == 2 && (rid != 1001U || rbuf[0] != 's')) {
|
||||
rpc_ok = false;
|
||||
}
|
||||
}
|
||||
while (true) {
|
||||
std::int32_t n = server.rpc_poll(&rid, rbuf, sizeof(rbuf));
|
||||
if (n <= 0) {
|
||||
break;
|
||||
}
|
||||
++got_server;
|
||||
if (rid != 7 || rbuf[0] != 'a') {
|
||||
rpc_ok = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
failures += check(rpc_ok && got_client == 2 && got_server == 1,
|
||||
"net: reliable RPCs both directions, ordered, intact");
|
||||
|
||||
client.destroy();
|
||||
server.destroy();
|
||||
|
||||
std::printf(failures == 0 ? "test_net_replication: all checks passed\n"
|
||||
: "test_net_replication: %d FAILED\n",
|
||||
failures);
|
||||
return failures == 0 ? 0 : 1;
|
||||
}
|
||||
@@ -461,9 +461,11 @@ internal static class Program
|
||||
or "EngineRenderer*" or "const EngineInputState*" or "uint8_t"
|
||||
or "EngineNavWorld*" or "const EngineNavWorld*"
|
||||
or "const EngineNavBox*" or "const EngineNavBakeConfig*"
|
||||
or "EngineNavStats*" or "uint8_t*"
|
||||
or "EngineNavStats*" or "uint8_t*" or "uint16_t"
|
||||
or "int32_t*" or "char*"
|
||||
or "EngineLogSinkCallback";
|
||||
or "EngineLogSinkCallback"
|
||||
or "EngineNetSession*" or "const EngineNetSession*"
|
||||
or "EngineNetTransformRow*" or "const EngineNetTransformRow*";
|
||||
if (!ok)
|
||||
{
|
||||
throw new InvalidOperationException($"{header}:{line}: unsupported ABI type '{ctype}' (extend the generator's type map deliberately).");
|
||||
@@ -559,6 +561,11 @@ internal static class Program
|
||||
"const EngineNavBakeConfig*" => "NavBakeConfig*",
|
||||
"EngineNavStats*" => "NavStats*",
|
||||
"uint8_t*" => "byte*",
|
||||
"uint16_t" => "ushort",
|
||||
"EngineNetSession*" => "EngineNetSession*",
|
||||
"const EngineNetSession*" => "EngineNetSession*",
|
||||
"EngineNetTransformRow*" => "NetTransformRow*",
|
||||
"const EngineNetTransformRow*" => "NetTransformRow*",
|
||||
_ => throw new InvalidOperationException($"no C# mapping for '{ctype}'"),
|
||||
};
|
||||
|
||||
@@ -627,6 +634,7 @@ internal static class Program
|
||||
EmitChunkView(sb, chunk);
|
||||
EmitChunkColumns(sb, structs.Single(s => s.Name == "EngineChunkColumns"));
|
||||
EmitInputState(sb, structs.Single(s => s.Name == "EngineInputState"));
|
||||
EmitNetTransformRow(sb, structs.Single(s => s.Name == "EngineNetTransformRow"));
|
||||
EmitCallback(sb, fns, header, lines);
|
||||
EmitApiTable(sb, table, fns);
|
||||
EmitLoader(sb);
|
||||
@@ -800,6 +808,24 @@ internal static class Program
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
// P7-04: EngineNetTransformRow mirror (u64 entity + 10 replicated
|
||||
// LocalTransform words = 48 bytes). Field list is ABI: fail closed.
|
||||
private static void EmitNetTransformRow(StringBuilder sb, StructDef def)
|
||||
{
|
||||
string[] wantNames = ["entity", "words"];
|
||||
string[] wantTypes = ["uint64_t", "float"];
|
||||
int[] wantArr = [0, 10];
|
||||
AssertStructFields(def, "EngineNetTransformRow", wantNames, wantTypes, wantArr);
|
||||
sb.AppendLine(" /// <summary>Mirror of EngineNetTransformRow: entity + 10 replicated words, 48 bytes.</summary>");
|
||||
sb.AppendLine(" [StructLayout(LayoutKind.Sequential, Pack = 8, Size = 48)]");
|
||||
sb.AppendLine(" public unsafe struct NetTransformRow");
|
||||
sb.AppendLine(" {");
|
||||
sb.AppendLine(" public ulong Entity;");
|
||||
sb.AppendLine(" public fixed float Words[10];");
|
||||
sb.AppendLine(" }");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
private static void EmitCallback(StringBuilder sb, List<FnDef> fns, string header, string[] lines)
|
||||
{
|
||||
var cb = ParseManagedCallback(lines, header);
|
||||
@@ -1258,6 +1284,13 @@ internal static class Program
|
||||
sb.AppendLine(" public void* Handle;");
|
||||
sb.AppendLine(" }");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(" /// <summary>Opaque network session (owns one ENet host + baselines + queues, P7-04).</summary>");
|
||||
sb.AppendLine(" [StructLayout(LayoutKind.Sequential, Pack = 8)]");
|
||||
sb.AppendLine(" public unsafe struct EngineNetSession");
|
||||
sb.AppendLine(" {");
|
||||
sb.AppendLine(" public void* Handle;");
|
||||
sb.AppendLine(" }");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
private static void EmitApiTable(StringBuilder sb, StructDef table, List<FnDef> fns)
|
||||
@@ -1376,9 +1409,9 @@ internal static class Program
|
||||
sb.AppendLine(" throw new InvalidOperationException($\"ABI column stride mismatch: native {table->ColumnStride} != managed {ColumnStride}.\");");
|
||||
sb.AppendLine(" }");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(" if (sizeof(LocalTransform) != 64 || sizeof(WorldTransform) != 64 || sizeof(Ray) != 24 || sizeof(RaycastHit) != 48 || sizeof(EngineChunkColumns) != 120 || sizeof(InputState) != 1668 || sizeof(NavBox) != 24 || sizeof(NavBakeConfig) != 48 || sizeof(NavStats) != 8 || sizeof(NavAgentData) != 64)");
|
||||
sb.AppendLine(" if (sizeof(LocalTransform) != 64 || sizeof(WorldTransform) != 64 || sizeof(Ray) != 24 || sizeof(RaycastHit) != 48 || sizeof(EngineChunkColumns) != 120 || sizeof(InputState) != 1668 || sizeof(NavBox) != 24 || sizeof(NavBakeConfig) != 48 || sizeof(NavStats) != 8 || sizeof(NavAgentData) != 64 || sizeof(NetTransformRow) != 48)");
|
||||
sb.AppendLine(" {");
|
||||
sb.AppendLine(" throw new InvalidOperationException($\"Component layout drift: LocalTransform={sizeof(LocalTransform)} WorldTransform={sizeof(WorldTransform)} Ray={sizeof(Ray)} Hit={sizeof(RaycastHit)} ChunkColumns={sizeof(EngineChunkColumns)} InputState={sizeof(InputState)} NavBox={sizeof(NavBox)} NavConfig={sizeof(NavBakeConfig)} NavStats={sizeof(NavStats)} NavAgent={sizeof(NavAgentData)}.\");");
|
||||
sb.AppendLine(" throw new InvalidOperationException($\"Component layout drift: LocalTransform={sizeof(LocalTransform)} WorldTransform={sizeof(WorldTransform)} Ray={sizeof(Ray)} Hit={sizeof(RaycastHit)} ChunkColumns={sizeof(EngineChunkColumns)} InputState={sizeof(InputState)} NavBox={sizeof(NavBox)} NavConfig={sizeof(NavBakeConfig)} NavStats={sizeof(NavStats)} NavAgent={sizeof(NavAgentData)} NetRow={sizeof(NetTransformRow)}.\");");
|
||||
sb.AppendLine(" }");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(" _table = table;");
|
||||
|
||||
Reference in new issue
Block a user