collinsmith / riiablo

Diablo II remade using Java and LibGDX
http://riiablo.com
Apache License 2.0
872 stars 99 forks source link

replace TCP networking with UDP #78

Closed collinsmith closed 4 years ago

collinsmith commented 4 years ago

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 uses ByteBuffer. I haven't looked into optimizations yet, but I'm curious if I can get Flatbuffers working using ByteBuf 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 including protocol 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 include contentSize 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:

  1. Include it as a fbs table "Header" which is a sibling to the packet data This was sort of a nightmare due to fbs mutability and when the Header table would be created. I worked in an API where the ReliableChannelHandler 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.
  2. Include the fields as siblings to the packet data An improvement on the first effort, but still had the mutability issue.
  3. Create a util class and prepend the UDP header data directly to the 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 on ByteBuf.

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 within ReliableChannelHandler, 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...

collinsmith commented 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).

collinsmith commented 4 years ago

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:

Defined packet types

  1. single frame Current packet type that's implemented -- should work for most packets.

  2. 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.

  3. 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.

  4. 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.

Packet data

  1. single frame

    protocolId   : uint8
    type         : uint8 (always 0)
    sequence     : uint16
    ack          : uint16
    ack_bits     : uint32
    size         : uint16
    [data]
  2. fragmented

    protocolId   : uint8
    type         : uint8 (always 1)
    sequence     : uint16
    fragmentId   : uint8
    numFragments : uint8
    [data]
  3. 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]
  4. chunked ack

    protocolId   : uint8
    type         : uint8 (always 3)
    sequence     : uint16
    chunkId      : uint8
    numSlices    : uint8
    ack_bits     : uint256 (one bit for every sliceId)
collinsmith commented 4 years ago

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.

collinsmith commented 4 years ago

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

collinsmith commented 4 years ago

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.

collinsmith commented 4 years ago

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.

Exception: ``` [ClientNetworkSyncronizer] Software caused connection abort: recv failed java.net.SocketException: Software caused connection abort: recv failed at java.net.SocketInputStream.socketRead0(Native Method) at java.net.SocketInputStream.socketRead(SocketInputStream.java:116) at java.net.SocketInputStream.read(SocketInputStream.java:171) at java.net.SocketInputStream.read(SocketInputStream.java:141) at java.nio.channels.Channels$ReadableByteChannelImpl.read(Channels.java:385) at com.riiablo.engine.client.ClientNetworkSyncronizer.begin(ClientNetworkSyncronizer.java:121) at com.artemis.BaseSystem.process(BaseSystem.java:45) at com.riiablo.profiler.ProfilerInvocationStrategy.processProfileSystem(ProfilerInvocationStrategy.java:73) at com.riiablo.profiler.ProfilerInvocationStrategy.processProfileSystems(ProfilerInvocationStrategy.java:65) at com.riiablo.profiler.ProfilerInvocationStrategy.process(ProfilerInvocationStrategy.java:39) at com.artemis.World.process(World.java:385) at com.riiablo.screen.GameScreen.render(GameScreen.java:692) at com.badlogic.gdx.Game.render(Game.java:46) at com.riiablo.Client.render(Client.java:416) at com.badlogic.gdx.backends.lwjgl.LwjglApplication.mainLoop(LwjglApplication.java:232) at com.badlogic.gdx.backends.lwjgl.LwjglApplication$1.run(LwjglApplication.java:127) ```