Closed collinsmith closed 4 years ago
01888faac66be749290af538e5f968fb3056a234 implemented support for option 3 into the Netty sandbox module. It was pretty painless. As expected, handling the UDP packet header is invisible at the application level. Being able to reject packets and process the header at the protocol level without touching the Flatbuffers data looks to be extremely useful.
I noticed that it might become an issue that ack
defaults to -1 (0xFFFFFFFF)
. This is encoded and sent as a ushort
0xFFFF
, so I'm worried about the repercussions of the server misinterpreting this as the client acking packet 0xFFFF
if that comes around. This would only occur if the client connected and first packet sent is when the server is on seq
0xFFFF
. This may resolve itself in the connection handshake where we just ignore ack
if handshake is being performed (after which, ack
should be set to a correct value).
Before I move forward, I want to establish my API to define the UDP packet headers. Currently, I only have support for single-frame packets, but I will need to add support for multi-frame packets. This will happen in massive syncs like changing acts, waypoints, or transferring save files across the network. I'll try and work with the below protocol and modify as needed, but I think this might work. This is a modified scheme based on what Gaffer recommends:
single frame Current packet type that's implemented -- should work for most packets.
fragmented
For time-sensitive packets that need 2-4 frames. This would break my current UDP reliability scheme since I'd probably use the same sequence id for multiple frames, but I may also just use new ones. This type would inherently not support ack
and shouldn't be used if reliability is needed.
chunked
A reliable version of packet type fragmented described as sending packets that need more than a few frames with the addition of reliability. This would be for packets that require ack
-- such as transferring a save file or large state syncs. Will include similar fields to type single frame so that we can track the slice indexes properly.
chunked ack Sibling of packet type chunked used to report the received packets. Not sure how this will be sent -- possibly as a response to every chunked packet.
single frame
protocolId : uint8
type : uint8 (always 0)
sequence : uint16
ack : uint16
ack_bits : uint32
size : uint16
[data]
fragmented
protocolId : uint8
type : uint8 (always 1)
sequence : uint16
fragmentId : uint8
numFragments : uint8
[data]
chunked
protocolId : uint8
type : uint8 (always 2)
sequence : uint16
chunkId : uint8
sliceId : uint8
numSlices : uint8
sliceSize : uint16 (only included with final slice, all others are 1Kb)
[data]
chunked ack
protocolId : uint8
type : uint8 (always 3)
sequence : uint16
chunkId : uint8
numSlices : uint8
ack_bits : uint256 (one bit for every sliceId)
Above seems to be incorrect -- the reliable.io implementation written by Gaffer is a bit different. I'm going to try my hand at porting the spec version to Java. It looks like fragmented, chunked and chunked ack packet types are combined.
The unreliable UDP packet framework should just about be completed. I decided to preemptively abstract down as much of the connection and packet sending protocol as I could in order to support TCP as well -- I'm concerned that there might be issues with my UDP framework and I wanted to make sure I could fall back on TCP if I need to during development -- also nice to have an alternative. I think much of my implementation could be simplified by using more of Netty's mechanisms and pipeline handlers, but this way of doing it seems to have worked so far. If Netty doesn't work out for whatever reason, I think this abstraction layer should make using LibGDX networking library a bit easier and keep the same code-base for all sockets (will try and keep ByteBuf
if this is the case since it seems better for networking use-cases).
I don't know if my API will work as-is, but the idea is that Endpoint
handles all message sending capabilities while PacketProcessor
handles all message receiving capabilities. The plan is to change the socket
instance in-engine to an endpoint
, and then when a packet needs to be sent Endpoint#sendMessage(ByteBuffer)
can be used to automatically build a packet/fragment/whatever and send it over the wire, and then a PacketProcessor
on the other end will receive a ByteBuf
containing the content of the packet and automatically convert it to a Flatbuffer table. I hope this sounds reasonable.
This is a C# implementation which includes 3 QoS modes Reliable, Unreliable, UnreliableOrdered
that I've been working with to write a port for Netty in Java: https://github.com/KillaMaaki/ReliableNetcode.NET
I've decided to change gears a bit on this and may push the change out a ways until proper testing and validation can be performed on my RUDP classes (a better handler pipeline might help this). In the interim, I want to try and implement a TCP server in Netty to replace the current D2GS and make it behave more like how a UDP server might, i.e., less focus on using client threads to identify packet senders and having client packets addressed. Starting to think it's about time to push this off until more people are involved in the project.
0e5bb72045b66c9d6fc97812587c116471e33175 added a functioning impl of D2GS using Netty with TCP connections, but I'm not happy with it (probably salvageable, but the code is god awful in places). I'm going to put this on the back burner. I think it's going to take at least one more iteration before the protocol is improved enough for me to feel comfortable. I learned a ton about working with Netty though, so I am confident I can make it work when the time comes. I'm going to close this issue and create one that's letter cluttered.
NOTE: As of the above commit, single player connections work, but there is an issue when multiple clients are connected.
77 made me realize the importance of trying to establish a foundation for the final version of the network code / protocols. Until this point, all networking is done via TCP, and while this protocol could work in the final game: 1, that's no fun, and 2, I think implementing UDP would provide a better online experience.
Firstly, as far as a networking framework goes, I've researched a bit and am looking into Netty. Kryonet may have been another option, but I think it's too tightly coupled with Kryo -- which I'm not using, nor am I really interested in using at this time. Another option may have been Apache MINA, but support doesn't look great (similar with Kryonet).
Netty contains ByteBuf, which looks very useful and performant, however interoperability with Java
ByteBuffer
may be an issue as Flatbuffers usesByteBuffer
. I haven't looked into optimizations yet, but I'm curious if I can get Flatbuffers working usingByteBuf
directly. I am concerned though that Netty uses big-endian while Flatbuffers uses little-endian and problems that forcing byte order might have.I had considered using TCP for the initial connection establishment and handshake before transitioning to UDP, but Gaffer recommends using no TCP at all and instead implementing a reliable UDP instead. Using multiple protocols could be a headache in and of itself -- plus some info in UDP will undoubtedly need reliability at some point.
UDP packet header should include at a minimum,
protocol, sequence, ack, ack_flags
. I'm includingprotocol
as either a version id or random id whenever my UDP packet format changes (not sure how I'll use it yet, but safe to say it's needed and plan for it). I'm also going to includecontentSize
because Flatbuffers provides FlatBufferBuilder#finishSizePrefixed(), and I'd like to roll this into my code instead.sequence, ack, ack_flags
have to do with making UDP reliable (see Gaffer).As far as how to include the UDP header data, I looked into 3 possibilities so far:
Header
" which is a sibling to the packet data This was sort of a nightmare due to fbs mutability and when theHeader
table would be created. I worked in an API where theReliableChannelHandler
was used to assist in constructing the packets, but I didn't like it and it meant tightly coupling packet data creation with the channel handler.ByteBuf
I stole this idea from how Flatbuffers manages their data and size prefixing. The idea here is to try and keep the UDP header data as far away from the Flatbuffer stuff as I can. UDP header data is managed by an API very similar to Flatbuffers, except it operates onByteBuf
.26aebd5183d5f470d99002d29a2995d114f3eed6 added support for option 3, and I'm going to continue playing around with it. I think this has the benefit of being the most interoperable with what I currently have done in the packet department. Incoming packets in Netty already exist as
ByteBuf
instances, so reading that data directly should be easy, and the Flatbuffers data can remain encapsulated and only be parsed once the UDP packet header is dealt with. Outgoing Flatbuffers data will have a UDP packet header stream prepended (I'm looking into using a CompositeByteBuf to composite my header stream and the Flatbuffers stream). Structuring the UDP packets this way is that it may help with debugging -- it's annoying having to encode an entire Flatbuffers stream and then decode it down the pipeline and validate or modify the UDP header data. Another huge benefit of this method, is that it should be completely invisible and handled entirely withinReliableChannelHandler
, which will read incoming headers and automatically generate and prepend outgoing headers as well as manage the whole reliability functionality.For now, there's a lot of https://gafferongames.com/ in my future...