Repository: X-Plane/elixir-raknet
Branch: main
Commit: fee7a4b97eb8
Files: 26
Total size: 114.2 KB
Directory structure:
gitextract_ft9zctf5/
├── .circleci/
│ └── config.yml
├── .formatter.exs
├── .github/
│ └── ISSUE_TEMPLATE/
│ ├── bug_report.md
│ └── feature_request.md
├── .gitignore
├── .idea/
│ └── runConfigurations/
│ ├── Format.xml
│ └── Test.xml
├── LICENSE
├── README.md
├── config/
│ ├── .credo.exs
│ └── credo_checks/
│ └── explicitly_ignore_return_values.ex
├── lib/
│ ├── application.ex
│ ├── connection.ex
│ ├── message.ex
│ ├── packet.ex
│ ├── reliability_layer.ex
│ ├── server.ex
│ └── system_address.ex
├── mix.exs
├── raknet.iml
└── test/
├── connection_test.exs
├── message_test.exs
├── packet_test.exs
├── server_test.exs
├── system_address_test.exs
└── test_helper.exs
================================================
FILE CONTENTS
================================================
================================================
FILE: .circleci/config.yml
================================================
# Elixir CircleCI 2.0 configuration file
#
# Check https://circleci.com/docs/2.0/language-elixir/ for more details
version: 2.1
orbs:
slack: circleci/slack@3.4.2
jobs:
build:
docker:
- image: cimg/elixir:1.14.1
environment: # environment variables for primary container
MIX_ENV: test
SEPARATE_IPV6_PORT: false
resource_class: medium
working_directory: ~/repo
steps:
- run: git clone https://github.com/X-Plane/elixir-raknet.git .
- run: git submodule update --init --remote
- run: mix local.hex --force
- restore_cache: # restores saved mix cache; Read about caching dependencies: https://circleci.com/docs/2.0/caching/
keys: # list of cache keys, in decreasing specificity
- v1-mix-cache-{{ .Branch }}-{{ checksum "mix.lock" }}
- v1-mix-cache-{{ .Branch }}
- v1-mix-cache
- restore_cache: # restores saved build cache
keys:
- v1-build-cache-{{ .Branch }}
- v1-build-cache
- run:
name: Compile
command: mix do deps.get, compile
- save_cache: # generate and store mix cache
key: v1-mix-cache-{{ .Branch }}-{{ checksum "mix.lock" }}
paths: "deps"
- save_cache: # don't forget to save a *build* cache, too
key: v1-build-cache-{{ .Branch }}
paths: "_build"
- run: LOG_LEVEL=warn SEPARATE_IPV6_PORT=false mix test --exclude ipv6:true
- store_test_results: # upload junit test results for display in Test Summary. More info: https://circleci.com/docs/2.0/collect-test-data/
path: _build/test/lib/raknet
- run: mix format --check-formatted
- run: bash -c "mix credo --strict --ignore tagtodo ; if [[ \$? -ge 16 ]] ; then exit 1 ; else exit 0 ; fi"
================================================
FILE: .formatter.exs
================================================
[
line_length: 140,
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]
================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.md
================================================
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior (including a [minimum reproducible example](https://stackoverflow.com/help/minimal-reproducible-example), as appropriate):
1. ...
2. ...
3. ...
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. macOS 10.15.4]
- Elixir Version [e.g. 1.11.1]
- Erlang Version [e.g. 23.0]
**Additional context**
Add any other context about the problem here.
================================================
FILE: .github/ISSUE_TEMPLATE/feature_request.md
================================================
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
================================================
FILE: .gitignore
================================================
# The directory Mix will write compiled artifacts to.
/_build/
# If you run "mix test --cover", coverage assets end up here.
/cover/
# The directory Mix downloads your dependencies sources to.
/deps/
# Where third-party dependencies like ExDoc output generated docs.
/doc/
# Ignore .fetch files in case you like to edit your project deps locally.
/.fetch
# If the VM crashes, it generates a dump, let's ignore it too.
erl_crash.dump
# Also ignore archive artifacts (built via "mix archive.build").
*.ez
# Ignore package tarball (built via "mix hex.build").
raknet-*.tar
================================================
FILE: .idea/runConfigurations/Format.xml
================================================
format
================================================
FILE: .idea/runConfigurations/Test.xml
================================================
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2021 X-Plane
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.
================================================
FILE: README.md
================================================
# Elixir RakNet
**`main` build status**: [](https://circleci.com/gh/X-Plane/elixir-raknet/tree/main) **Latest commit build status**: [](https://circleci.com/gh/X-Plane/elixir-raknet)
This is an Elixir implementation of the [RakNet](https://github.com/facebookarchive/RakNet)/RakLib networking communication protocol.
It offers things like stateful connections, reliable (or unreliable) UDP transmissions, and client clock synchronization, all of which are generally necessary for implementing a multiplayer game server.
Note that this is not a *complete* implementation of the RakNet protocol—it currently offers only what X-Plane needs for its massive multiplayer server. Known limitations include:
1. The server doesn't do well with retransmitting unacknowledged reliable packets, since we only ever send unreliable packets in our MMO server.
2. We don't support splitting packets larger than the connection's MTU size—this leaves the responsibility on the client to make sure your packets aren't over the size limit (which of course can vary depending on the client's connection—yikes!). In practice, this isn't a problem if you know your packets are reasonably small (well under 1 KB).
We'd welcome well-tested pull requests to fix these things, though. (See the [Contributing](#contributing) section below.)
## Installation
Add `raknet` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:raknet, git: "git://github.com/X-Plane/elixir-raknet.git", branch: "main"},
]
end
```
## Usage
In your application, you'll need two things:
1. A client state struct that implements the `RakNet.Client` protocol. For the X-Plane massive multiplayer server, our state struct looks like this:
defmodule MmoServer.Client.State do
@moduledoc "State for our implementation of RakNet.Client"
@enforce_keys [:session_id, :connection_pid]
defstruct session_id: -1,
connection_pid: nil,
# Other fields, which get default-initialized
# when a new connection is negotiated.
. . .
end
Then, in our implementation of the `RakNet.Client` protocol, we spin up a GenServer for each connection as follows:
defimpl RakNet.Client, for: MmoServer.Client.State do
def new(_client_struct, connection_pid, _) do
new_state = %MmoServer.Client.State{
connection_pid: connection_pid,
session_id: MmoServer.SessionIdServer.new_id()
}
MmoServer.Client.start_link(new_state)
new_state
end
def receive(%MmoServer.Client.State{session_id: id}, packet_type, packet_buffer, transit_time) do
MmoServer.Client.handle_packet(id, packet_type, packet_buffer, transit_time)
end
def got_ack(%MmoServer.Client.State{session_id: _id}, _send_receipt_id) do
# X-Plane doesn't actually do anything with packet acknowledgements
nil
end
def disconnect(%MmoServer.Client.State{session_id: id}) do
MmoServer.Client.disconnect(id)
end
end
2. One or more `RakNet.Server` GenServers (one per port you want to accept connections on). For the X-Plane MMO server, we accept connections on a range of ports, like this:
localhost = {127, 0, 0, 1}
make_server_spec = fn port ->
# Only your client state type and port are required
[MmoServer.Client.State, port, include_timestamp_with_datagrams: true, host: localhost, client_timeout_ms: 20 * 60 * 1_000, open_ipv6_socket: true]
end
servers =
Enum.map(port_min..port_max, fn port ->
{RakNet.Server, make_server_spec.(port)}
end)
opts = [strategy: :one_for_one, name: MmoServer.Supervisor]
Supervisor.start_link(servers, opts)
One configuration option above is worth calling out explicitly: the value of `:open_ipv6_socket` will determine whether we try to open a *separate* socket to receive IPv6 connections, or whether we accept IPv6 connections over the same socket as IPv4. The configuration you need here will depend on your OS configuration, but in general, Linux systems default to sharing a socket, while macOS defaults to using separate sockets. Alternatively, you can set a default value for this using the `SEPARATE_IPV6_PORT` environment variable.
From this point forward, the `RakNet.Server` will create a new stateful `RakNet.Connection` for each RakNet client that connects on your port, and client packets will be forwarded to your client struct.
The client can send messages using the `connection_pid` it was constructed with, like so:
RakNet.Connection.send(state.connection_pid, :reliable, packet)
...where the final argument is a bitstring, and the second argument is the reliability level the packet should be transmitted with. Supported reliability levels are defined in `RakNet.ReliabilityLayer.Reliability` as:
- `:unreliable`
- `:unreliable_sequenced`
- `:reliable`
- `:reliable_ordered`
- `:reliable_sequenced`
- `:unreliable_ack_receipt`
- `:reliable_ack_receipt`
- `:reliable_ordered_ack_receipt`
## Running the tests
You can run the complete unit test suite via the standard `$ mix test` from the top-level directory. Note that you'll see a log message about receiving data before the connection negotiation finished—that's deliberate, since we test handling that error case, but it would be somewhat concerning for it to occur in production.
[We use CircleCI](https://app.circleci.com/pipelines/github/X-Plane/elixir-raknet) to run the test suite on every commit.
You can run the same tests that CircleCI does as follows:
1. Run the Credo linter: `$ mix credo --strict`
2. Confirm the code matches the official formatter: `$ mix format --check-formatted`
3. Confirm the tests pass: `$ mix test` (or if you like more verbose output, `$ mix test --trace`)
## Contributing
Before submitting a pull request, please ensure:
1. You've added appropriate tests for your new changes
2. All tests pass
3. The credo analysis is clean: `$ mix credo --strict --ignore tagtodo`
4. You've run `$ mix format`
================================================
FILE: config/.credo.exs
================================================
# This file contains the configuration for Credo and you are probably reading
# this after creating it with `mix credo.gen.config`.
#
# If you find anything wrong or unclear in this file, please report an
# issue on GitHub: https://github.com/rrrene/credo/issues
#
%{
#
# You can have as many configs as you like in the `configs:` field.
configs: [
%{
#
# Run any exec using `mix credo -C `. If no exec name is given
# "default" is used.
#
name: "default",
#
# These are the files included in the analysis:
files: %{
#
# You can give explicit globs or simply directories.
# In the latter case `**/*.{ex,exs}` will be used.
#
included: ["lib/", "src/", "test/", "web/", "apps/"],
excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/", ~r"/data/"]
},
#
# Load and configure plugins here:
#
plugins: [],
#
# If you create your own checks, you must specify the source files for
# them here, so they can be loaded by Credo before running the analysis.
#
requires: [
"config/credo_checks/explicitly_ignore_return_values.ex"
],
#
# If you want to enforce a style guide and need a more traditional linting
# experience, you can change `strict` to `true` below:
#
strict: true,
#
# If you want to use uncolored output by default, you can change `color`
# to `false` below:
#
color: true,
#
# You can customize the parameters of any check by adding a second element
# to the tuple.
#
# To disable a check put `false` as second element:
#
# {Credo.Check.Design.DuplicatedCode, false}
#
checks: [
#
## Consistency Checks
#
{Credo.Check.Consistency.ExceptionNames, []},
{Credo.Check.Consistency.LineEndings, []},
{Credo.Check.Consistency.ParameterPatternMatching, []},
{Credo.Check.Consistency.SpaceAroundOperators, []},
{Credo.Check.Consistency.SpaceInParentheses, []},
{Credo.Check.Consistency.TabsOrSpaces, []},
#
## Design Checks
#
# You can customize the priority of any check
# Priority values are: `low, normal, high, higher`
#
{Credo.Check.Design.AliasUsage, [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]},
# Don't cause to-do comments to fail
{Credo.Check.Design.TagTODO, [exit_status: 0]},
{Credo.Check.Design.TagFIXME, []},
#
## Readability Checks
#
{Credo.Check.Readability.AliasOrder, []},
{Credo.Check.Readability.FunctionNames, []},
{Credo.Check.Readability.LargeNumbers, []},
{Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 140]},
{Credo.Check.Readability.ModuleAttributeNames, []},
{Credo.Check.Readability.ModuleDoc, []},
{Credo.Check.Readability.ModuleNames, []},
{Credo.Check.Readability.ParenthesesInCondition, []},
{Credo.Check.Readability.ParenthesesOnZeroArityDefs, []},
{Credo.Check.Readability.PredicateFunctionNames, []},
{Credo.Check.Readability.PreferImplicitTry, []},
{Credo.Check.Readability.RedundantBlankLines, []},
{Credo.Check.Readability.Semicolons, []},
{Credo.Check.Readability.SpaceAfterCommas, []},
{Credo.Check.Readability.StringSigils, []},
{Credo.Check.Readability.TrailingBlankLine, []},
{Credo.Check.Readability.TrailingWhiteSpace, []},
{Credo.Check.Readability.UnnecessaryAliasExpansion, []},
{Credo.Check.Readability.VariableNames, []},
#
## Refactoring Opportunities
#
{Credo.Check.Refactor.CondStatements, []},
{Credo.Check.Refactor.CyclomaticComplexity, []},
{Credo.Check.Refactor.FunctionArity, []},
{Credo.Check.Refactor.LongQuoteBlocks, []},
{Credo.Check.Refactor.MatchInCondition, []},
{Credo.Check.Refactor.NegatedConditionsInUnless, []},
{Credo.Check.Refactor.NegatedConditionsWithElse, []},
{Credo.Check.Refactor.Nesting, [max_nesting: 3]},
{Credo.Check.Refactor.UnlessWithElse, []},
{Credo.Check.Refactor.WithClauses, []},
#
## Warnings
#
{Credo.Check.Warning.BoolOperationOnSameValues, []},
{Credo.Check.Warning.ExpensiveEmptyEnumCheck, []},
{Credo.Check.Warning.IExPry, []},
{Credo.Check.Warning.IoInspect, []},
{Credo.Check.Warning.MixEnv, []},
{Credo.Check.Warning.OperationOnSameValues, []},
{Credo.Check.Warning.OperationWithConstantResult, []},
{Credo.Check.Warning.RaiseInsideRescue, []},
{Credo.Check.Warning.UnusedEnumOperation, []},
{Credo.Check.Warning.UnusedFileOperation, []},
{Credo.Check.Warning.UnusedKeywordOperation, []},
{Credo.Check.Warning.UnusedListOperation, []},
{Credo.Check.Warning.UnusedPathOperation, []},
{Credo.Check.Warning.UnusedRegexOperation, []},
{Credo.Check.Warning.UnusedStringOperation, []},
{Credo.Check.Warning.UnusedTupleOperation, []},
#
# Controversial and experimental checks (opt-in, just replace `false` with `[]`)
#
{Credo.Check.Consistency.MultiAliasImportRequireUse, []},
# Tyler says: I don't like this one... it just enforces that you always name your unused vars the *same* way
# either anonymously (like `_`) or with a real name (like `_client`)
{Credo.Check.Consistency.UnusedVariableNames, false},
# Tyler says: I like this one
{Credo.Check.Design.DuplicatedCode, []},
{Credo.Check.Readability.AliasAs, []},
{Credo.Check.Readability.MultiAlias, []},
{Credo.Check.Readability.Specs, false},
{Credo.Check.Readability.SeparateAliasRequire, false},
{Credo.Check.Readability.SinglePipe, []},
{Credo.Check.Readability.WithCustomTaggedTuple, []},
{Credo.Check.Refactor.ABCSize, false},
{Credo.Check.Refactor.AppendSingleItem, []},
{Credo.Check.Refactor.DoubleBooleanNegation, []},
{Credo.Check.Refactor.ModuleDependencies, false},
{Credo.Check.Refactor.PipeChainStart, false},
# Tyler says: I like this one
{Credo.Check.Refactor.VariableRebinding, []},
# Tyler says: I like this one
{Credo.Check.Warning.MapGetUnsafePass, []},
{Credo.Check.Warning.UnsafeToAtom, []},
#
# Custom checks can be created using `mix credo.gen.check`.
#
{ExplicitlyIgnoreReturnValues,
[
ignore: [
{[:Appsignal, :Transaction], :finish},
{[:Appsignal, :Transaction], :complete},
{[:HTTPoison], :start},
{[:RakNet, :Connection], :handle_message},
{[:RakNet, :Connection], :stop},
{[:RakNet, :Client], :got_ack},
{[:RakNet, :Client], :receive},
{[:RakNet, :Client], :disconnect},
{[:MmoServer, :Dsf], :remove},
{[:MmoServer, :Dsf], :update},
{[:MmoServer, :WorldCache], :put},
{[:MmoServer, :WorldCache], :drop},
{[:MmoServer, :SessionIdServer], :return_id}
]
]}
]
}
]
}
================================================
FILE: config/credo_checks/explicitly_ignore_return_values.ex
================================================
defmodule ExplicitlyIgnoreReturnValues do
@moduledoc """
This is a horrifying hack.
It is a complete copy & paste of Credo's stock unused_operation and unused_function_return_helper,
but modified slightly to reverse the logic; instead of specifying modules and types to warn about when unused,
we specify an "allow list" of the *only* modules & functions to allow ignoring.
See original copypasta source here:
https://github.com/rrrene/credo/blob/master/lib/credo/check/warning/unused_operation.ex
https://github.com/rrrene/credo/blob/master/lib/credo/check/warning/unused_function_return_helper.ex
"""
@funs_to_allow_ignoring [
{[:Enum], :each},
{[:GenServer], :cast},
{[:GenServer], :stop},
{[:IO], :inspect},
{[:IO], :puts},
{[:IO], :warn},
{[:IO], :write},
{[:Logger], :configure},
{[:Logger], :debug},
{[:Logger], :error},
{[:Logger], :flush},
{[:Logger], :info},
{[:Logger], :log},
{[:Logger], :warn},
{[:Process], :cancel_timer},
{[:Process], :exit},
{[:Process], :flag},
{[:Process], :hibernate},
{[:Process], :register},
{[:Process], :sleep},
{[:Process], :unlink},
{[:Process], :unregister},
{[:Registry], :register},
{[:Registry], :unregister},
{[:Supervisor], :stop}
]
@block_ops_with_head_expr [:if, :unless, :case, :for, :quote]
use Credo.Check, base_priority: :high, category: :warning
def explanations do
[
check: @moduledoc,
params: [
ignore:
"A list of function AST names whose return values we should allow ignoring. " <>
"Elements of the list should look like: {[:Enum], :each} or {[:Custom, :Name, :Here], :function}"
]
]
end
def param_defaults, do: [ignore: []]
def run(source_file, params \\ []) do
issue_meta = Credo.IssueMeta.for(source_file, params)
funs_to_ignore = @funs_to_allow_ignoring ++ Keyword.fetch!(params, :ignore)
Enum.reduce(find_unused_calls(source_file, params, funs_to_ignore), [], fn invalid_call, issues ->
{_, meta, _} = invalid_call
trigger =
invalid_call
|> Macro.to_string()
|> String.split("(")
|> List.first()
# credo:disable-for-next-line
issues ++ [issue_for(&format_issue/2, issue_meta, meta[:line], trigger)]
end)
end
defp issue_for(format_issue_fun, issue_meta, line_no, trigger) do
format_issue_fun.(
issue_meta,
message: "There should be no unused return values.",
trigger: trigger,
line_no: line_no
)
end
@def_ops [:def, :defp, :defmacro]
@block_ops_with_head_expr [:if, :unless, :case, :for, :quote]
alias Credo.Code.Block
alias Credo.SourceFile
def find_unused_calls(%SourceFile{} = source_file, _params, funs_to_allow_ignoring) do
Credo.Code.prewalk(source_file, &traverse_defs(&1, &2, funs_to_allow_ignoring))
end
for op <- @def_ops do
defp traverse_defs({unquote(op), _meta, arguments} = ast, acc, functions) when is_list(arguments) do
candidates = Credo.Code.prewalk(ast, &find_candidates(&1, &2, functions))
if Enum.any?(candidates) do
{nil, acc ++ filter_unused_calls(ast, candidates)}
else
{ast, acc}
end
end
end
defp traverse_defs(ast, acc, _) do
{ast, acc}
end
#
defp find_candidates({{:., _, [{:__aliases__, _, modules}, function]}, _, _} = ast, acc, functions) do
if {modules, function} in functions do
{ast, acc}
else
# credo:disable-for-next-line
{ast, acc ++ [ast]}
end
end
defp find_candidates(ast, acc, _) do
{ast, acc}
end
# TODO: Everything below this line is unmodified from unused_function_return_helper.ex. If these functions are ever made public, use them.
defp filter_unused_calls(ast, candidates) do
candidates
|> Enum.map(&detect_unused_call(&1, ast))
|> Enum.reject(&is_nil/1)
end
defp detect_unused_call(candidate, ast) do
ast
|> Credo.Code.postwalk(&traverse_verify_candidate(&1, &2, candidate), :not_verified)
|> verified_or_unused_call(candidate)
end
defp verified_or_unused_call(:VERIFIED, _), do: nil
defp verified_or_unused_call(_, candidate), do: candidate
#
defp traverse_verify_candidate(ast, acc, candidate) do
if Credo.Code.contains_child?(ast, candidate) do
verify_candidate(ast, acc, candidate)
else
{ast, acc}
end
end
# we know that `candidate` is part of `ast`
for op <- @def_ops do
defp verify_candidate({unquote(op), _, arguments} = ast, :not_verified = _acc, candidate)
when is_list(arguments) do
# IO.inspect(ast, label: "#{unquote(op)} (#{Macro.to_string(candidate)} #{acc})")
if last_call_in_do_block?(ast, candidate) || last_call_in_rescue_block?(ast, candidate) do
{nil, :VERIFIED}
else
{nil, :FALSIFIED}
end
end
end
defp last_call_in_do_block?(ast, candidate) do
ast
|> Block.calls_in_do_block()
|> List.last()
|> Credo.Code.contains_child?(candidate)
end
defp last_call_in_rescue_block?(ast, candidate) do
ast
|> Block.calls_in_rescue_block()
|> List.last()
|> Credo.Code.contains_child?(candidate)
end
for op <- @block_ops_with_head_expr do
defp verify_candidate({unquote(op), _, arguments} = ast, :not_verified = acc, candidate)
when is_list(arguments) do
# IO.inspect(ast, label: "#{unquote(op)} (#{Macro.to_string(candidate)} #{acc})")
head_expression = Enum.slice(arguments, 0..-2)
if Credo.Code.contains_child?(head_expression, candidate) do
{nil, :VERIFIED}
else
{ast, acc}
end
end
end
defp verify_candidate({:=, _, _} = ast, :not_verified = acc, candidate) do
# IO.inspect(ast, label: ":= (#{Macro.to_string(candidate)} #{acc})")
if Credo.Code.contains_child?(ast, candidate) do
{nil, :VERIFIED}
else
{ast, acc}
end
end
defp verify_candidate(
{:__block__, _, arguments} = ast,
:not_verified = acc,
candidate
)
when is_list(arguments) do
# IO.inspect(ast, label: ":__block__ (#{Macro.to_string(candidate)} #{acc})")
last_call = List.last(arguments)
if Credo.Code.contains_child?(last_call, candidate) do
{ast, acc}
else
{nil, :FALSIFIED}
end
end
defp verify_candidate(
{:|>, _, arguments} = ast,
:not_verified = acc,
candidate
) do
# IO.inspect(ast, label: ":__block__ (#{Macro.to_string(candidate)} #{acc})")
last_call = List.last(arguments)
if Credo.Code.contains_child?(last_call, candidate) do
{ast, acc}
else
{nil, :VERIFIED}
end
end
defp verify_candidate({:->, _, arguments} = ast, :not_verified = acc, _candidate)
when is_list(arguments) do
# IO.inspect(ast, label: ":-> (#{Macro.to_string(ast)} #{acc})")
{ast, acc}
end
defp verify_candidate({:fn, _, arguments} = ast, :not_verified = acc, _candidate)
when is_list(arguments) do
{ast, acc}
end
defp verify_candidate(
{:try, _, arguments} = ast,
:not_verified = acc,
candidate
)
when is_list(arguments) do
# IO.inspect(ast, label: "try (#{Macro.to_string(candidate)} #{acc})")
after_block = Block.after_block_for!(ast)
if after_block && Credo.Code.contains_child?(after_block, candidate) do
{nil, :FALSIFIED}
else
{ast, acc}
end
end
# my_fun()
defp verify_candidate(
{fun_name, _, arguments} = ast,
:not_verified = acc,
candidate
)
when is_atom(fun_name) and is_list(arguments) do
# IO.inspect(ast, label: "my_fun() (#{Macro.to_string(candidate)} #{acc})")
if Credo.Code.contains_child?(arguments, candidate) do
{nil, :VERIFIED}
else
{ast, acc}
end
end
# module.my_fun()
defp verify_candidate(
{{:., _, [{module, _, []}, fun_name]}, _, arguments} = ast,
:not_verified = acc,
candidate
)
when is_atom(fun_name) and is_atom(module) and is_list(arguments) do
# IO.inspect(ast, label: "Mod.fun() (#{Macro.to_string(candidate)} #{acc})")
if Credo.Code.contains_child?(arguments, candidate) do
{nil, :VERIFIED}
else
{ast, acc}
end
end
# :erlang_module.my_fun()
defp verify_candidate(
{{:., _, [module, fun_name]}, _, arguments} = ast,
:not_verified = acc,
candidate
)
when is_atom(fun_name) and is_atom(module) and is_list(arguments) do
# IO.inspect(ast, label: "Mod.fun() (#{Macro.to_string(candidate)} #{acc})")
if Credo.Code.contains_child?(arguments, candidate) do
{nil, :VERIFIED}
else
{ast, acc}
end
end
# MyModule.my_fun()
defp verify_candidate(
{{:., _, [{:__aliases__, _, mods}, fun_name]}, _, arguments} = ast,
:not_verified = acc,
candidate
)
when is_atom(fun_name) and is_list(mods) and is_list(arguments) do
# IO.inspect(ast, label: "Mod.fun() (#{Macro.to_string(candidate)} #{acc})")
if Credo.Code.contains_child?(arguments, candidate) do
{nil, :VERIFIED}
else
{ast, acc}
end
end
defp verify_candidate(ast, acc, _candidate) do
# IO.inspect(ast, label: "_ (#{Macro.to_string(candidate)} #{acc})")
{ast, acc}
end
end
================================================
FILE: lib/application.ex
================================================
defmodule RakNet.Application do
@moduledoc "Supervision tree for RakNet connections"
use Application
def start(_type, _args) do
Logger.configure(
level:
case(System.fetch_env("LOG_LEVEL")) do
{:ok, "debug"} -> :debug
{:ok, "info"} -> :info
{:ok, "warn"} -> :warn
{:ok, "error"} -> :error
_ -> :info
end
)
children = [
{Registry, keys: :unique, name: RakNet.Connection}
]
opts = [strategy: :one_for_one, name: RakNet.Supervisor]
Supervisor.start_link(children, opts)
end
end
================================================
FILE: lib/connection.ex
================================================
defprotocol RakNet.Client do
def new(client_struct, connection_pid, module_data)
def receive(client, packet_type, packet_buffer, time_comp)
def got_ack(client, send_receipt_id)
def disconnect(client)
end
defmodule RakNet.Connection do
@moduledoc "A stateful connection to a client"
use GenServer, restart: :transient
require Logger
alias RakNet.Message
alias RakNet.ReliabilityLayer
alias RakNet.ReliabilityLayer.Reliability
import XUtil.Map
import XUtil.Time, only: [unix_timestamp_ms: 0]
defmodule State do
@moduledoc "The state we pass around within a RakNet.Connection GenServer"
@enforce_keys [
:host,
:port,
:encoded_host,
:client_ips_and_ports,
:encoded_client,
:server_identifier,
:client_module,
:respond,
:base_time
]
defstruct host: nil,
port: nil,
encoded_host: <<>>,
# IPs may be 4 ipv4 octets, or 8 ipv6 hextets
client_ips_and_ports: [],
encoded_client: <<>>,
server_identifier: <<>>,
# The RakNet.Client module that implements your game logic client; this receives game-specific packets and
# uses the respond function we give it to communicate back to the user.
client_module: nil,
# Data you've asked us to pass to your client_module's new/2 factory
client_data: %{},
# The means the RakNet.Server that created us gave us to send a message across the wire to the client
respond: nil,
# The :os.system_time(:millisecond) time at which we were created.
# Use this to get RakNet.Server.timestamp() values relative to creation time
base_time: 0,
# Milliseconds of inactivity before we time out this connection and cause it to self-destruct.
# We count as "activity" a) connected pongs, and b) encapsulated client data packets
# (but not RakNet protocol overhead, like conneciton handshakes)
timeout_ms: 30_000,
# Corresponds to #define INCLUDE_TIMESTAMP_WITH_DATAGRAMS
# (which is in turn enabled by USE_SLIDING_WINDOW_CONGESTION_CONTROL in the RakNetDefinesOverrides.h)
include_timestamp_with_datagrams: false,
# Corresponds to #define MAXIMUM_NUMBER_OF_INTERNAL_IDS
max_number_of_internal_ids: 10,
# Server doesn't need to set any of this
receive_sequence: nil,
# Sequencing for whole messages
send_sequence: 0,
message_index: 0,
ordered_write_index: 0,
# Maps send_sequence indices to lists of (reliable only?) packets
unacknowledged_sent: %{},
# Sequencing for sequenced-but-not-ordered messages
sequenced_packet: 0,
packet_buffer: [],
ack_buffer: [],
mtu_size: 0,
# The :os.system_time(:millisecond) time at which we enqueued the oldest unset acknowledgement packet
oldest_unsent_ack_time_ms: 0,
# Last diff between our timestamp we sent with a ping and the time we received the pong
last_rtts: [],
# A TRef to the timer that will time out this connection and cause it to self-destruct
timeout_ref: nil,
# The instance of business_logic_module that tracks the state for this connection
client: nil
end
defmodule Resendable do
@moduledoc "A set of packets that we'll resend if we don't get an :ack"
@enforce_keys [:packets, :index, :next_resend_time]
defstruct packets: [], index: 0, next_resend_time: 0
end
@udp_header_size 28
# Sync rate is hardcoded to 1/100 sec in RakNet
@sync_ms 10
@ping_ms 5000
# This is hard-coded in RakNet, to prevent you from using all your RAM on a single client that fails to respond to
# a big stream of reliable packets
@max_tracked_reliable_packets 512
# RakNet calculated RTO dynamically based on round trip time variance and the like... we're just gonna make it 1 second + rtt
@retransmition_time_out_ms 1000
# RakNet calculates round-trip time (RTT) as the average of your up-to-5 last ping times
@rtt_window_size 5
def start_link(%State{} = state) do
GenServer.start_link(__MODULE__, state)
end
def stop(connection_pid), do: GenServer.stop(connection_pid, :shutdown)
def handle_message(connection_pid, message_type, data) do
GenServer.cast(connection_pid, {message_type, data})
end
@doc """
Returns one of:
{:ok, nil} (if you didn't request an ack receipt)
{:ok, ack_receipt_id}
{:error, message}
"""
def send(connection_pid, reliability, message)
def send(connection_pid, reliability, message)
when is_bitstring(message) and reliability in [:unreliable_ack_receipt, :reliable_ack_receipt, :reliable_ordered_ack_receipt] do
{:ok, GenServer.call(connection_pid, {:send, reliability, message})}
end
def send(connection_pid, reliability, message) when is_bitstring(message) and is_atom(reliability) do
GenServer.cast(connection_pid, {:send, reliability, message})
{:ok, nil}
end
################ Server Implementation ################
@impl GenServer
def init(state) do
{:ok, _} = :timer.send_interval(@sync_ms, :sync)
Process.flag(:trap_exit, true)
{:ok, reschedule_timeout(state)}
end
@raknet_protocol_version 6
@use_security 0
@impl GenServer
def handle_info(:sync, connection) do
# TODO: Variable retransmission timeout based on RTT: send if estimatedTimeToNextTick+curTime < oldestUnsentAck+rto-RTT
{:noreply,
connection
|> sync_ack_buffer()
|> sync_requeue_reliable_data_packets()
|> sync_enqueued_data_packets()}
end
@impl GenServer
def handle_info(:sync_ping, connection) do
{:noreply, ping(connection)}
end
@impl GenServer
def handle_info({:EXIT, _pid, reason}, connection) do
Logger.debug("Connection exiting due to reason #{inspect(reason)}")
if not is_nil(connection.client) do
RakNet.Client.disconnect(connection.client)
end
Process.exit(self(), :kill)
end
defp sync_ack_buffer(connection) do
current_time = unix_timestamp_ms()
if connection.oldest_unsent_ack_time_ms > 0 and
(connection.oldest_unsent_ack_time_ms <= current_time - @sync_ms or length(connection.ack_buffer) > 20) do
ack(sweep_line(connection.ack_buffer), connection.respond, connection)
%{connection | ack_buffer: [], oldest_unsent_ack_time_ms: 0}
else
connection
end
end
defp sync_enqueued_data_packets(connection) do
if Enum.empty?(connection.packet_buffer) do
connection
else
updated_conn = send_immediate(connection.packet_buffer, connection)
%{updated_conn | packet_buffer: []}
end
end
defp sync_requeue_reliable_data_packets(connection) do
current_time = RakNet.Server.timestamp()
# PerfTODO: This is more expensive than it needs to be... Instead use a queue of {resend_time, packet}?
to_resend =
Map.values(connection.unacknowledged_sent)
|> Enum.sort_by(select_key(:next_resend_time))
|> Enum.take_while(fn resendable -> resendable.next_resend_time <= current_time end)
if Enum.empty?(to_resend) do
connection
else
packets = to_resend |> Enum.map(select_key(:packets)) |> List.flatten()
# When the packets actually go out, we'll re-add them to unacknowledged_sent queue (with a new index)
indices = Enum.map(to_resend, select_key(:index))
%{
connection
| packet_buffer: packets ++ connection.packet_buffer,
unacknowledged_sent: Map.drop(connection.unacknowledged_sent, indices)
}
end
end
defp ping(connection) do
enqueue(:unreliable, make_ping_buffer(connection.base_time), connection)
end
defp send_immediate(packets, connection) when is_list(packets) do
encoded =
RakNet.Packet.encode(%{
sequence_number: connection.send_sequence,
encapsulated_packets: connection.packet_buffer,
timestamp: if(connection.include_timestamp_with_datagrams, do: RakNet.Server.timestamp(connection.base_time), else: -1)
})
# TODO: Periodically retransmit unacknowledged reliable packets
# TODO: Split packets larger than connection.mtu_size
# credo:disable-for-next-line
connection.respond.(<>, List.first(connection.client_ips_and_ports))
{reliable_packets, _} = Enum.split_with(packets, fn %ReliabilityLayer.Packet{reliability: r} -> Reliability.is_reliable?(r) end)
updated_unacknowledged =
if Enum.empty?(reliable_packets) or map_size(connection.unacknowledged_sent) > @max_tracked_reliable_packets do
connection.unacknowledged_sent
else
Map.put(connection.unacknowledged_sent, connection.send_sequence, %Resendable{
index: connection.send_sequence,
packets: reliable_packets,
next_resend_time: RakNet.Server.timestamp() + rtt(connection) * 1.5 + @retransmition_time_out_ms
})
end
%{
connection
| unacknowledged_sent: updated_unacknowledged,
ordered_write_index: 1 + max(connection.ordered_write_index, max_packet_value(reliable_packets, :order_index)),
message_index: 1 + max(connection.message_index, max_packet_value(reliable_packets, :message_index)),
send_sequence: connection.send_sequence + 1
}
end
defp max_packet_value(packets, packet_key, default \\ -1) when is_list(packets) and is_atom(packet_key) do
case Enum.max_by(packets, select_key(packet_key), fn -> default end) do
%ReliabilityLayer.Packet{} = p ->
case Map.fetch!(p, packet_key) do
num when is_number(num) -> num
_ -> default
end
_ ->
default
end
end
@impl GenServer
def handle_cast({:open_connection_request_1, data}, connection) do
Logger.debug("Received open connection request 1 from #{inspect(List.first(connection.client_ips_and_ports))}")
<<_offline_msg_id::binary-size(16), @raknet_protocol_version::size(8), _zero_pad_to_mtu_size::bitstring>> = data
# +1 for the message ID byte (:open_connection_request_1)
mtu_size = byte_size(data) + @udp_header_size + 1
message =
<>
# credo:disable-for-next-line
connection.respond.(message, List.first(connection.client_ips_and_ports))
Logger.debug("Sent open connection reply 1 to #{inspect(List.first(connection.client_ips_and_ports))}")
{:noreply, %{connection | mtu_size: mtu_size}}
rescue
err ->
Logger.error("Failed to parse open connection request 1 of #{byte_size(data)} bytes (needed at least 24 bytes)")
Logger.error("Packet was: #{inspect(data)}")
Logger.error("Connection state was: #{inspect(connection)}")
reraise(err, __STACKTRACE__)
end
@impl GenServer
def handle_cast({:open_connection_request_2, data}, connection) do
Logger.debug("Received open connection request 2 from #{inspect(List.first(connection.client_ips_and_ports))}")
server_addr_bytes = byte_size(data) - 16 - 2 - 8
<<_offline_id::binary-size(16), _svr_address::binary-size(server_addr_bytes), mtu_size::size(16), _client_id::size(64)>> = data
connection.respond.(
<>,
# credo:disable-for-next-line
List.first(connection.client_ips_and_ports)
)
Logger.debug("Sent open connection reply 2 to #{inspect(List.first(connection.client_ips_and_ports))}")
{:noreply, connection}
end
@impl GenServer
def handle_cast({:ping, <>}, connection) do
{:noreply, enqueue(:unreliable, make_pong_buffer(ping_time, connection.base_time), reschedule_timeout(connection))}
end
@impl GenServer
def handle_cast({:pong, <>}, %State{last_rtts: prev_rtts} = connection) do
ping_time = RakNet.Server.timestamp(connection.base_time) - our_sent_time
updated_rtts = [ping_time | Enum.take(prev_rtts, @rtt_window_size - 1)]
{:noreply, reschedule_timeout(%{connection | last_rtts: updated_rtts})}
end
@impl GenServer
def handle_cast({:ack, packet}, %State{unacknowledged_sent: unacked} = connection) do
timestamp_size = if connection.include_timestamp_with_datagrams, do: 32, else: 0
# TODO: Do something with the timestamp if > 0
<<_timestamp::size(timestamp_size), ack_portion::binary>> = packet
{removed, still_unacked} = Map.split(unacked, message_indices_from_ack(ack_portion))
msgs_received =
removed
|> Map.values()
# Values from the removed map are Resendable structs; we just need to inspect the packets
|> Enum.flat_map(select_key(:packets))
|> Enum.filter(fn %ReliabilityLayer.Packet{reliability: r} -> Reliability.needs_client_ack?(r) end)
|> Enum.map(select_key(:message_index))
|> MapSet.new()
Enum.each(msgs_received, fn msg_idx -> RakNet.Client.got_ack(connection.client, msg_idx) end)
{:noreply, %{connection | unacknowledged_sent: still_unacked}}
end
@impl GenServer
def handle_cast({:nack, _packet}, connection) do
# TODO: Resend?
{:noreply, connection}
end
@impl GenServer
def handle_cast({:client_connect, data}, connection) do
Logger.debug("Received client connect from #{inspect(List.first(connection.client_ips_and_ports))}")
<<_client_id::size(64), time_sent::size(64), @use_security::size(8), _password::binary>> = data
send_pong = RakNet.Server.timestamp(connection.base_time)
# TODO: Support IPv6
# TODO: Should we offer other ports clients can connect on?
empty_ip =
RakNet.SystemAddress.encode(%{
version: 4,
address: {255, 255, 255, 255},
port: 0
})
packet =
<> <>
:erlang.list_to_binary([connection.encoded_host] ++ List.duplicate(empty_ip, 9)) <>
<>
Logger.debug("Sent server handshake to #{inspect(List.first(connection.client_ips_and_ports))}")
{:noreply, enqueue(:reliable_ordered, packet, connection)}
end
@impl GenServer
def handle_cast({:client_handshake, data}, connection) do
Logger.debug("Received client handshake from #{inspect(List.first(connection.client_ips_and_ports))}")
# A system address is: 1 byte v4 or v6, followed by EITHER 4 bytes v4 address + 2 bytes port OR 28 bytes v6 addr & port
addresses_length = bit_size(data) - 2 * 64
<<_addresses::bitstring-size(addresses_length), ping_time::size(64), pong_time::size(64)>> = data
# We send the first ping immediately, then subsequent pings every 5 seconds
updated_conn =
enqueue(
:unreliable,
[
make_ping_buffer(connection.base_time),
make_pong_buffer(ping_time, connection.base_time)
],
connection
)
{:ok, _} = :timer.send_interval(@ping_ms, :sync_ping)
rtt = max(0, pong_time - RakNet.Server.timestamp(connection.base_time))
client = RakNet.Client.new(connection.client_module, self(), connection.client_data)
Logger.debug("Finalized connection handshake with #{inspect(List.first(connection.client_ips_and_ports))}")
{:noreply, %{updated_conn | last_rtts: [rtt], client: client}}
end
@impl GenServer
def handle_cast({:client_disconnect, _data}, connection) do
Logger.debug("Client #{inspect(List.first(connection.client_ips_and_ports))} disconnected")
if not is_nil(connection.client) do
RakNet.Client.disconnect(connection.client)
end
Process.exit(self(), :normal)
{:noreply, connection}
end
@impl GenServer
def handle_cast({packet_type, data}, %State{} = connection) when is_atom(packet_type) do
%{encapsulated_packets: encapsulated_packets, sequence_number: recv_sequence, timestamp: _timestamp} =
if connection.include_timestamp_with_datagrams do
RakNet.Packet.decode_with_timestamp(data)
else
RakNet.Packet.decode_no_timestamp(data)
end
if Enum.empty?(encapsulated_packets) do
# Had a parsing error!
Logger.error("Failed to parse #{packet_type} (#{byte_size(data) + 1} bytes) #{inspect(data, limit: :infinity)}")
{:noreply, connection}
else
{:noreply,
encapsulated_packets
|> Enum.reduce({connection, :unacknowledged}, fn packet, {conn, acked} ->
<> = packet.buffer
ident_atom = Message.name(identifier)
is_connection_negotiation = ident_atom in [:ping, :pong, :client_connect, :client_handshake, :client_disconnect]
finished_connection_negotiation = not Enum.empty?(conn.last_rtts) and conn.client != nil
if is_connection_negotiation or finished_connection_negotiation do
# TODO: Sequence indices are per-channel
# TODO: Immediately send acks for split packets
updated_conn =
if acked == :unacknowledged do
%{buffer_ack(recv_sequence, conn) | receive_sequence: max(conn.receive_sequence, recv_sequence)}
else
conn
end
if is_connection_negotiation do
{elem(handle_cast({ident_atom, head_data}, updated_conn), 1), :acknowledged}
else
# TODO: Support custom packet type atoms (right now we're passing through the integer values as the identifier)
RakNet.Client.receive(updated_conn.client, identifier, head_data, rtt(updated_conn) / 2)
{reschedule_timeout(updated_conn), :acknowledged}
end
else
Logger.info("Connection to #{inspect(conn.host)}:#{conn.port} received data before connection negotiation finished")
# credo:disable-for-next-line
conn.respond.(<>, List.first(conn.client_ips_and_ports))
{conn, acked}
end
end)
|> elem(0)}
end
end
@impl GenServer
def handle_cast({:send, reliability, message}, %State{} = connection) do
{:noreply, sync_enqueued_data_packets(enqueue(reliability, message, connection))}
end
@impl GenServer
def handle_call({:send, reliability, message}, _from, %State{} = connection) do
updated_conn = sync_enqueued_data_packets(enqueue(reliability, message, connection))
{:reply, updated_conn.message_index, updated_conn}
end
@impl GenServer
def terminate(reason, connection) do
Logger.info("Terminating #{inspect(connection.host)}:#{connection.port} due to #{inspect(reason)}")
Registry.unregister(RakNet.Connection, {connection.host, connection.port})
end
defp reschedule_timeout(%State{timeout_ref: nil} = connection) do
case :timer.exit_after(connection.timeout_ms, self(), :timeout) do
{:ok, timer_id} -> %{connection | timeout_ref: timer_id}
_ -> connection
end
end
defp reschedule_timeout(%State{} = connection) do
case :timer.cancel(connection.timeout_ref) do
{:ok, _} -> reschedule_timeout(%{connection | timeout_ref: nil})
# I guess we try killing it again later?
_ -> connection
end
end
defp enqueue(reliability, buffer, connection) when is_atom(reliability) and is_bitstring(buffer) do
enqueue(reliability, [buffer], connection)
end
defp enqueue(reliability, buffers, connection)
when (reliability == :unreliable_sequenced or reliability == :reliable_sequenced or reliability == :reliable_ordered_ack_receipt) and
is_list(buffers) do
num_buffers = length(buffers)
new_packets =
buffers
|> Enum.zip(0..(num_buffers - 1))
|> Enum.map(fn {buffer, idx} ->
%ReliabilityLayer.Packet{
reliability: reliability,
message_index: if(Reliability.is_reliable?(reliability), do: connection.message_index, else: nil),
order_index: connection.ordered_write_index,
sequencing_index: connection.sequenced_packet + idx,
buffer: buffer
}
end)
%{connection | packet_buffer: new_packets ++ connection.packet_buffer, sequenced_packet: connection.sequenced_packet + num_buffers}
end
defp enqueue(reliability, buffers, connection) when is_atom(reliability) and is_list(buffers) do
if Reliability.valid?(reliability) do
new_packets =
Enum.map(buffers, fn buffer ->
%ReliabilityLayer.Packet{
reliability: reliability,
message_index:
if(Reliability.is_reliable?(reliability) or Reliability.needs_client_ack?(reliability),
do: connection.message_index,
else: nil
),
order_index: if(Reliability.is_sequenced?(reliability), do: connection.ordered_write_index, else: nil),
buffer: buffer
}
end)
%{connection | packet_buffer: new_packets ++ connection.packet_buffer}
else
Logger.error("Unknown reliability atom #{reliability}")
connection
end
end
defp buffer_ack(packet_index, connection) when is_integer(packet_index) do
case connection.ack_buffer do
[] -> %{connection | ack_buffer: [packet_index], oldest_unsent_ack_time_ms: unix_timestamp_ms()}
_ -> %{connection | ack_buffer: [packet_index | connection.ack_buffer]}
end
end
@doc """
The sweep line algorithm: https://en.wikipedia.org/wiki/Sweep_line_algorithm
Given a collection of integers, it combines them into the minimum number of contiguous ranges.
iex> RakNet.Connection.sweep_line(MapSet.new([5, 4, 3, 2, 1, 0]))
[{0, 5}]
iex> RakNet.Connection.sweep_line([5, 4, 3, 100, 1, 0])
[{0, 1}, {3, 5}, {100, 100}]
"""
def sweep_line(integers) do
[start | sorted] = Enum.sort(integers)
# Traverse the sorted array, while maintaining a set of "active" events:
# whenever you see a start/end time, respectively add or drop the
# corresponding event from the set, and add (if the active set is non-empty)
# an event to your solution.
sorted
|> Enum.reduce([{start, start}], fn packet_idx, [{b, e} | rest] = acc ->
if packet_idx == e + 1 do
[{b, packet_idx} | rest]
else
[{packet_idx, packet_idx} | acc]
end
end)
|> Enum.sort()
end
defp ack(buffered_acks, responder, connection) when is_list(buffered_acks) do
{timestamp_bits, timestamp} =
if connection.include_timestamp_with_datagrams do
{32, RakNet.Server.timestamp(connection.base_time)}
else
{0, 0}
end
responder.(
<> <>
:erlang.list_to_binary(
Enum.map(buffered_acks, fn {range_min, range_max} ->
min_is_max = if range_min == range_max, do: 1, else: 0
<> <>
if(min_is_max == 1, do: <<>>, else: <>)
end)
),
# credo:disable-for-next-line
List.first(connection.client_ips_and_ports)
)
end
def message_indices_from_ack(<> = full) do
parsed =
Enum.reduce(1..packet_count, {[], remainder}, fn _, {indices_to_drop, rem_binary} ->
case rem_binary do
# 1 -> range min == range max (i.e., it's a single index)
<<1::size(8), rmin::little-size(24), rem::binary>> -> {[rmin | indices_to_drop], rem}
<<0::size(8), rmin::little-size(24), rmax::little-size(24)>> -> {Enum.to_list(rmin..rmax) ++ indices_to_drop, <<>>}
<<0::size(8), rmin::little-size(24), rmax::little-size(24), rem::binary>> -> {Enum.to_list(rmin..rmax) ++ indices_to_drop, rem}
_ -> :error
end
end)
case parsed do
{msg_indices_to_drop, <<>>} ->
msg_indices_to_drop
_ ->
Logger.error("Ack packet failed to parse: #{inspect(full)}")
[]
end
end
defp make_ping_buffer(base_time) do
<>
end
defp make_pong_buffer(ping_time, base_time) do
<>
end
# Assume a high-but-not-crazy ping until the connection negotiation is finished (for the purpose of scheduling retries)
defp rtt(%State{last_rtts: []}), do: 200
defp rtt(%State{last_rtts: rtts}), do: Enum.sum(rtts) / length(rtts)
end
================================================
FILE: lib/message.ex
================================================
defmodule RakNet.Message do
@moduledoc """
Message types that RakNet can send. These are the first 8 bits of the packet.
Follows RakLib protocol: https://github.com/pmmp/RakLib/tree/master/src/protocol
"""
@names_and_vals %{
# These come from RakNet's DefaultMessageIDTypes enum (in MessageIdentifiers.h)
:ping => 0x00,
:unconnected_ping => 0x01,
:unconnected_ping_open_connections => 0x02,
:pong => 0x03,
:open_connection_request_1 => 0x05,
:open_connection_reply_1 => 0x06,
:open_connection_request_2 => 0x07,
:open_connection_reply_2 => 0x08,
# ID_CONNECTION_REQUEST
:client_connect => 0x09,
# ID_CONNECTION_REQUEST_ACCEPTED: Tell the client the connection request accepted
:server_handshake => 0x10,
# ID_CONNECTION_ATTEMPT_FAILED: Sent to the player when a connection request cannot be completed
:connection_attempt_failed => 0x11,
# ID_NEW_INCOMING_CONNECTION: A remote client has successfully connected
:client_handshake => 0x13,
:client_disconnect => 0x15,
# ID_CONNECTION_LOST: Reliable packet delivery failed, connection closed
:connection_lost => 0x16,
# ID_INCOMPATIBLE_PROTOCOL_VERSION
:incompatible_version => 0x19,
:unconnected_pong => 0x1C,
:advertise_system => 0x1D,
# These come from RakNet's reliability layer and "congestion control"---they are datagram headers
# The indices are the priority (0 to 15)
:data_packet_0 => 0x80,
:data_packet_1 => 0x81,
:data_packet_2 => 0x82,
:data_packet_3 => 0x83,
:data_packet_4 => 0x84,
:data_packet_5 => 0x85,
:data_packet_6 => 0x86,
:data_packet_7 => 0x87,
:data_packet_8 => 0x88,
:data_packet_9 => 0x89,
:data_packet_A => 0x8A,
:data_packet_B => 0x8B,
:data_packet_C => 0x8C,
:data_packet_D => 0x8D,
:data_packet_E => 0x8E,
:data_packet_F => 0x8F,
:nack => 0xA0,
:ack => 0xC0
}
@msg_names MapSet.new(Map.keys(@names_and_vals))
@msg_binary_vals MapSet.new(Map.values(@names_and_vals))
@vals_and_names Map.new(@names_and_vals, fn {name, val} -> {val, name} end)
@doc """
Set of all message types listed above---:ping, :unconnected_ping, :pong, etc.
"""
def known_message_names, do: @msg_names
@doc """
Set of all hex values that act as packet type identifiers.
E.g., 0x0 for ping, 0x3 for pong, 0x1d for advertise system, etc.
"""
def known_messages, do: @msg_binary_vals
@doc """
True if we have a name atom for this message type
"""
def is_known?(message_binary) when is_integer(message_binary), do: MapSet.member?(@msg_binary_vals, message_binary)
def is_protocol_data?(message_binary) when is_integer(message_binary), do: message_binary in 0x80..0x8F
@doc """
The message name atom for this binary message; :error if we don't recognize it
"""
def name(message_binary) when is_integer(message_binary), do: Map.get(@vals_and_names, message_binary, :error)
def binary(message_name) when is_atom(message_name), do: Map.fetch!(@names_and_vals, message_name)
# "Magic" bytes used to distinguish offline messages from garbage
def offline_msg_id, do: <<0, 255, 255, 0, 254, 254, 254, 254, 253, 253, 253, 253, 18, 52, 86, 120>>
end
================================================
FILE: lib/packet.ex
================================================
defmodule RakNet.Packet do
@moduledoc "Encoding & decoding utils for RakLib data, taken from ExRakLib"
require Logger
alias RakNet.ReliabilityLayer
alias RakNet.ReliabilityLayer.Reliability
def decode_with_timestamp(<>) do
Map.put(decode_no_timestamp(data), :timestamp, timestamp)
end
def decode_no_timestamp(data, internal \\ false) do
<> = :erlang.iolist_to_binary(data)
%{
sequence_number: sequence_number,
encapsulated_packets: decode_no_timestamp(rest, [], internal),
timestamp: -1
}
rescue
e ->
Logger.error(inspect(e))
%{sequence_number: -1, encapsulated_packets: []}
end
defp decode_no_timestamp("", encapsulated_packets, _internal) do
Enum.reverse(encapsulated_packets)
end
defp decode_no_timestamp(rest, encapsulated_packets, internal) do
{packet, rest} = decode_encapsulated_packet(rest, internal)
decode_no_timestamp(rest, [packet | encapsulated_packets], internal)
end
def encode(%{sequence_number: seq, encapsulated_packets: packets, timestamp: ts}) when ts >= 0 do
:erlang.iolist_to_binary([<>, Enum.map(packets, &encode_encapsulated_packet(&1, false))])
end
def encode(%{sequence_number: seq_number, encapsulated_packets: encapsulated}, internal \\ false) do
:erlang.iolist_to_binary([<>, Enum.map(encapsulated, &encode_encapsulated_packet(&1, internal))])
end
def decode_encapsulated_packet(data, internal) do
<> = data
is_reliable = Reliability.is_reliable?(reliability)
is_sequenced = Reliability.is_sequenced?(reliability)
{length, identifier_ack, post_length} =
if internal do
<> = post_header
{length, identifier_ack, rest}
else
<> = post_header
{trunc(Float.ceil(length / 8)), nil, rest}
end
# message_index is actually the sequencing index for :unreliable_sequenced
{message_index, post_reliability} =
if is_reliable or is_sequenced do
<> = post_length
{message_index, rest}
else
{nil, post_length}
end
{order_index, order_channel, post_ordering} =
if is_sequenced do
<> = post_reliability
{order_index, order_channel, rest}
else
{nil, nil, post_reliability}
end
{split_count, split_id, split_index, post_split} =
if has_split > 0 do
<> = post_ordering
{split_count, split_id, split_index, rest}
else
{nil, nil, nil, post_ordering}
end
<> = post_split
{%ReliabilityLayer.Packet{
reliability: Reliability.name(reliability),
has_split: has_split,
length: length,
identifier_ack: identifier_ack,
message_index: if(is_reliable, do: message_index, else: nil),
sequencing_index: if(is_reliable, do: nil, else: message_index),
order_index: order_index,
order_channel: order_channel,
split_count: split_count,
split_id: split_id,
split_index: split_index,
buffer: buffer
}, rest}
end
def encode_encapsulated_packet(%ReliabilityLayer.Packet{} = p, internal) do
if ReliabilityLayer.Packet.valid?(p) do
is_reliable = Reliability.is_reliable?(p.reliability)
is_sequenced = Reliability.is_sequenced?(p.reliability)
index = if is_reliable, do: p.message_index, else: p.sequencing_index
# TODO: Support splitting: https://github.com/mhsjlw/node-raknet/blob/master/src/client.js#L144
<> <>
if internal do
<>
else
<>
end <>
if is_reliable or is_sequenced do
<> <>
if is_sequenced do
<>
else
<<>>
end
else
<<>>
end <>
if p.has_split > 0 do
<>
else
<<>>
end <>
p.buffer
else
Logger.error("Invalid packet: #{inspect(p, binaries: :as_binaries, limit: :infinity)}")
<<>>
end
end
end
================================================
FILE: lib/reliability_layer.ex
================================================
defmodule RakNet.ReliabilityLayer.Reliability do
@moduledoc "Taken from RakNet's PacketPriority.h"
@names_and_vals %{
:unreliable => 0,
:unreliable_sequenced => 1,
:reliable => 2,
:reliable_ordered => 3,
:reliable_sequenced => 4,
# These are the same as unreliable/reliable/reliable ordered, except that the business logic provider
# will get an :ack message when the client acknowledges receipt
:unreliable_ack_receipt => 5,
:reliable_ack_receipt => 6,
:reliable_ordered_ack_receipt => 7
}
@vals_and_names Map.new(@names_and_vals, fn {name, val} -> {val, name} end)
@doc """
The message name atom for this binary message; :error if we don't recognize it
"""
def name(reliability_binary) when is_integer(reliability_binary), do: Map.get(@vals_and_names, reliability_binary, :error)
def binary(reliability_name) when is_atom(reliability_name), do: Map.fetch!(@names_and_vals, reliability_name)
def valid?(reliability_atom) when is_atom(reliability_atom), do: Map.has_key?(@names_and_vals, reliability_atom)
def is_reliable?(reliability_binary) when is_integer(reliability_binary), do: reliability_binary in [2, 3, 4, 6, 7]
def is_reliable?(reliability_atom) when is_atom(reliability_atom), do: binary(reliability_atom) in [2, 3, 4, 6, 7]
def is_ordered?(reliability_binary) when is_integer(reliability_binary), do: reliability_binary == 3
def is_ordered?(reliability_atom) when is_atom(reliability_atom), do: reliability_atom == :reliable_ordered
def is_sequenced?(reliability_binary) when is_integer(reliability_binary), do: reliability_binary in [1, 3, 4, 7]
def is_sequenced?(reliability_atom) when is_atom(reliability_atom), do: binary(reliability_atom) in [1, 3, 4, 7]
def needs_client_ack?(reliability_binary) when is_integer(reliability_binary), do: reliability_binary in [5, 6, 7]
def needs_client_ack?(reliability_atom) when is_atom(reliability_atom), do: binary(reliability_atom) in [5, 6, 7]
end
defmodule RakNet.ReliabilityLayer.Packet do
@moduledoc "See ReliabilityLayer.cpp, ReliabilityLayer::WriteToBitStreamFromInternalPacket()"
alias RakNet.ReliabilityLayer.Reliability
@enforce_keys [:reliability, :buffer]
defstruct priority: 4,
reliability: Reliability.binary(:reliable_ordered),
has_split: 0,
length: -1,
# Used for internal packets only
identifier_ack: nil,
# Used for all reliable types
message_index: nil,
# Used for UNRELIABLE_SEQUENCED, RELIABLE_SEQUENCED
sequencing_index: nil,
# Used for UNRELIABLE_SEQUENCED, RELIABLE_SEQUENCED, RELIABLE_ORDERED.
order_index: nil,
order_channel: 0,
# Split packets only
split_count: nil,
split_id: nil,
split_index: nil,
# The actual (encapsulated) packet data
buffer: nil
# credo:disable-for-next-line
def valid?(%RakNet.ReliabilityLayer.Packet{} = p) do
msg_idx_ok = not Reliability.is_reliable?(p.reliability) or (is_integer(p.message_index) and p.message_index >= 0)
order_idx_ok = not Reliability.is_sequenced?(p.reliability) or (is_integer(p.order_index) and p.order_index >= 0)
split_ok =
p.has_split == 0 or
(is_integer(p.split_count) and p.split_count > 0 and
is_integer(p.split_id) and is_integer(p.split_index))
msg_idx_ok and order_idx_ok and split_ok and
p.priority >= 0 and p.priority < 0xF and
Reliability.valid?(p.reliability) and p.buffer != nil
end
end
================================================
FILE: lib/server.ex
================================================
defmodule RakNet.Server do
@moduledoc """
A server implementing the RakNet protocol
"""
require Logger
import XUtil.Time, only: [unix_timestamp_ms: 0]
defmodule Spawner do
@moduledoc "A dumb wrapper to make a spawned function supervisable"
def start_link(impl_fn), do: {:ok, spawn(impl_fn)}
end
def child_spec([receiver | [port | options]]) do
%{
# String.to_atom/1 is fine here, because we'll be creating a very limited number of listeners per port
# credo:disable-for-next-line
id: String.to_atom("#{__MODULE__}:#{port}"),
start: {__MODULE__, :start_link, [receiver, port, options]},
restart: :permanent,
type: :worker
}
end
def start_link(client_module, port, options \\ []) when is_atom(client_module) and is_integer(port) do
defaults = [
custom_packets: [],
custom_types: [],
client_data: %{},
client_timeout_ms: 10_000,
# Corresponds to #define INCLUDE_TIMESTAMP_WITH_DATAGRAMS
# (which is in turn enabled by USE_SLIDING_WINDOW_CONGESTION_CONTROL in the RakNetDefinesOverrides.h)
include_timestamp_with_datagrams: false,
# Corresponds to #define MAXIMUM_NUMBER_OF_INTERNAL_IDS
max_number_of_internal_ids: 10,
open_ipv4_socket: true,
open_ipv6_socket: System.get_env("SEPARATE_IPV6_PORT") != "false",
# TODO: use gen_udp directly and reopen the socket if it gets closed
send: &Socket.Datagram.send!/3,
server_identifier: make_unique_id(),
offline_ping_response: "RakNet Server"
]
host = Keyword.get(options, :host, {0, 0, 0, 0})
config =
defaults
|> Keyword.merge(options)
|> Enum.into(%{
client_module: struct(client_module),
encoded_host: RakNet.SystemAddress.encode(%{address: host, port: port})
})
socket_v4 = if config[:open_ipv4_socket], do: elem(open_socket(4, port), 1), else: nil
socket_v6 = if config[:open_ipv6_socket], do: elem(open_socket(6, port), 1), else: nil
sockets = {socket_v4, socket_v6}
Supervisor.start_link(
[
# String.to_atom/1 is fine here, because we'll be creating a very limited number of listeners per port
# credo:disable-for-lines:2
%{id: String.to_atom("#{__MODULE__}:#{port}_ipv4"), start: {Spawner, :start_link, [fn -> serve_v4(sockets, config) end]}},
%{id: String.to_atom("#{__MODULE__}:#{port}_ipv6"), start: {Spawner, :start_link, [fn -> serve_v6(sockets, config) end]}}
],
strategy: :one_for_one
)
end
defp open_socket(ip_version, port) when ip_version == 4 or ip_version == 6 do
addl_args = if ip_version == 4, do: [:inet], else: [:inet6, {:ipv6_v6only, true}]
:gen_udp.open(port, [:binary, {:active, false}] ++ addl_args)
end
@doc "The current Unix timestamp, in milliseconds"
def timestamp(offset \\ 0), do: unix_timestamp_ms() - offset
@doc "A 64-bit unique ID"
def make_unique_id, do: <>
defp serve_v4({nil, _}, _config), do: :ok
defp serve_v4({socket_v4, _} = sockets, config), do: serve_impl(&serve_v4/2, socket_v4, sockets, config)
defp serve_v6({_, nil}, _config), do: :ok
defp serve_v6({_, socket_v6} = sockets, config), do: serve_impl(&serve_v6/2, socket_v6, sockets, config)
defp serve_impl(server, receive_socket, sockets, config) do
# We have to do basic decoding in the main process, because it may update our client connection state. :(
case Socket.Datagram.recv!(receive_socket) do
nil ->
server.(sockets, config)
{packet, {client_ip, client_port}} = received_raw ->
case decode(received_raw, sockets, config) do
# Send to a client process to parse & optionally respond (never do any work here, since this is a single process)
{:connected, {client, packet_type, data}} -> RakNet.Connection.handle_message(client, packet_type, data)
{:unconnected, decoded} -> spawn(fn -> handle_unconnected_packet(sockets, config, decoded) end)
_ -> Logger.error("Failed to decode packet from #{inspect(client_ip)}:#{client_port}\nPacket: #{inspect(packet)}")
end
end
server.(sockets, config)
end
# One of:
# {:error, %{}}
# {:unconnected, {client_ip_and_port, :unconnected_ping, data}}
# {:connected, {client, packet_type, data}}
defp decode({packet, client_ip_and_port}, sockets, config) do
case packet_decode(packet, config) do
{:error, _msg} = err ->
err
{:ok, :open_connection_request_1, data} ->
{:connected, {open_connection(sockets, client_ip_and_port, config), :open_connection_request_1, data}}
{:ok, :unconnected_ping, data} ->
{:unconnected, {client_ip_and_port, :unconnected_ping, data}}
{:ok, :unconnected_ping_open_connections, data} ->
{:unconnected, {client_ip_and_port, :unconnected_ping_open_connections, data}}
{:ok, packet_type, data} ->
case lookup(client_ip_and_port) do
nil -> {:unconnected, {client_ip_and_port, packet_type, data}}
client -> {:connected, {client, packet_type, data}}
end
end
end
defp open_connection(sockets, {host, port} = client_ip_and_port, config) do
existing_client = lookup(client_ip_and_port)
# TODO: Nuke the existing client, create a new one
if existing_client do
Logger.debug("Existing client requested to open a new connection")
RakNet.Connection.stop(existing_client)
else
Logger.debug("Open a new connection to #{inspect(host)}:#{port}")
end
{:ok, new_client} =
RakNet.Connection.start_link(%RakNet.Connection.State{
host: host,
port: port,
encoded_host: config[:encoded_host],
client_ips_and_ports: [client_ip_and_port],
encoded_client: RakNet.SystemAddress.encode(%{address: host, port: port}),
timeout_ms: config[:client_timeout_ms],
base_time: timestamp(),
server_identifier: config[:server_identifier],
client_module: config[:client_module],
client_data: config[:client_data],
include_timestamp_with_datagrams: config[:include_timestamp_with_datagrams],
max_number_of_internal_ids: config[:max_number_of_internal_ids],
respond: make_responder(sockets, config)
})
# Do *not* bring down the whole server if the connection dies; if that happens,
# we'll just start a new proceses next time we hear from the user.
Process.unlink(new_client)
Registry.register(RakNet.Connection, client_ip_and_port, new_client)
new_client
end
defp lookup(client_ip_and_port) do
case Registry.lookup(RakNet.Connection, client_ip_and_port) do
[{_, client}] ->
if Process.alive?(client) do
client
else
Registry.unregister(RakNet.Connection, client_ip_and_port)
nil
end
_ ->
nil
end
end
defp packet_decode(<>, _config) do
case RakNet.Message.name(identifier) do
:error -> {:error, "Unknown packet identifier"}
name -> {:ok, name, data}
end
end
defp handle_unconnected_packet(sockets, config, {client_ip_port, :unconnected_ping, <>}) do
send_unconnected_pong(sockets, client_ip_port, ping_time, config)
end
defp handle_unconnected_packet(sockets, config, {client_ip_port, :unconnected_ping_open_connections, <>}) do
send_unconnected_pong(sockets, client_ip_port, ping_time, config)
end
defp handle_unconnected_packet(sockets, config, {client_ip_port, _other, _data}) do
send(sockets, <>, client_ip_port, config)
end
defp send_unconnected_pong(sockets, client_ip_port, ping_time, config) do
send(
sockets,
<>,
client_ip_port,
config
)
end
defp make_responder(sockets, config) do
fn packet, ip_and_port ->
send(sockets, packet, ip_and_port, config)
end
end
defp send(sockets, packet, client_ip_and_port, config) do
sockets
|> choose_socket(client_ip_and_port)
|> config[:send].(packet, client_ip_and_port)
end
defp choose_socket({socket_v4, socket_v6}, client_ip_and_port) do
case RakNet.SystemAddress.ip_version(client_ip_and_port) do
4 -> socket_v4
6 -> socket_v6
end
end
end
================================================
FILE: lib/system_address.ex
================================================
defmodule RakNet.SystemAddress do
@moduledoc "Tools for encoding & decoding IP addresses & ports"
def encode(%{version: 4, address: {o1, o2, o3, o4}, port: port}),
do: <<4::size(8), o1::unsigned-size(8), o2::unsigned-size(8), o3::unsigned-size(8), o4::unsigned-size(8), port::unsigned-size(16)>>
def encode(%{version: 6, address: {h1, h2, h3, h4, h5, h6, h7, h8}, port: port}) do
<<6::size(8), 28::unsigned-size(8), 30::unsigned-size(8), port::unsigned-size(16), 0::size(32), h1::unsigned-size(16),
h2::unsigned-size(16), h3::unsigned-size(16), h4::unsigned-size(16), h5::unsigned-size(16), h6::unsigned-size(16),
h7::unsigned-size(16), h8::unsigned-size(16), 0::size(32)>>
end
def encode(%{address: addr, port: _port} = args) do
encode(Map.put(args, :version, ip_version(addr)))
end
# Octet and hextet versions
def ip_version({_, _, _, _}), do: 4
def ip_version({_, _, _, _, _, _, _, _}), do: 6
# Host-and-port patterns
def ip_version({{_, _, _, _}, port}) when is_integer(port), do: 4
def ip_version({{_, _, _, _, _, _, _, _}, port}) when is_integer(port), do: 6
def decode_many(addresses_and_ports) when is_bitstring(addresses_and_ports) do
decode_address_port(addresses_and_ports)
end
defp decode_address_port(bin, prev \\ [])
defp decode_address_port(<<4::size(8), address::binary-size(4), port::unsigned-size(16), rest::binary>>, prev) do
<> = address
decode_address_port(rest, [%{version: 4, address: {o1, o2, o3, o4}, port: port} | prev])
end
# Note: ipv6 addresses are serialized as their whole sockaddr_in6 struct
# Fields are:
# 1. Length of the struct (28 bytes == 224 bits)
# 2. sa_family_t, fixed value of AF_INET6 == 30 (0x1e)
# 3. port
# 4. flow info (???)
# 5. in6_addr (128 bits)
# 6. scope ID (???)
defp decode_address_port(<<6::size(8), 28::unsigned-size(8), 30::unsigned-size(8), ipv6_body::bitstring-size(208), rest::binary>>, prev) do
<> = ipv6_body
<> = addr
decode_address_port(rest, [%{version: 6, address: {a1, a2, a3, a4, a5, a6, a7, a8}, port: port} | prev])
end
defp decode_address_port(<<>>, prev) when is_list(prev), do: prev
end
================================================
FILE: mix.exs
================================================
defmodule RakNet.MixProject do
use Mix.Project
def project do
[
app: :raknet,
version: "0.1.0",
build_path: "_build",
deps_path: "deps",
lockfile: "mix.lock",
elixir: "~> 1.11",
elixirc_options: [warnings_as_errors: halt_on_warnings?(Mix.env())],
start_permanent: Mix.env() == :prod,
consolidate_protocols: Mix.env() != :test,
deps: deps()
]
end
def application do
[
extra_applications: [:logger],
mod: {RakNet.Application, []}
]
end
defp deps do
[
{:x_util, git: "https://github.com/X-Plane/elixir-xutil.git", branch: "main"},
{:socket, "~> 0.3.13"},
{:assertions, "~> 0.10", only: :test},
{:credo, "~> 1.5.1", only: [:dev, :test], runtime: false}
]
end
# Clever hack to allow unused functions and the like in test, but not dev or prod:
# https://blog.rentpathcode.com/elixir-warnings-as-errors-sometimes-f5a8d2c96b15
defp halt_on_warnings?(:test), do: false
defp halt_on_warnings?(_), do: true
end
================================================
FILE: raknet.iml
================================================
================================================
FILE: test/connection_test.exs
================================================
defmodule RakNet.ConnectionTest do
use ExUnit.Case, async: true
doctest RakNet.Connection
import RakNet.Connection, only: [message_indices_from_ack: 1]
require Assertions
import Assertions, only: [assert_lists_equal: 2]
@min_is_max 1
@min_is_not_max 0
@packet_count_1 1
test "handles single acks" do
assert [2] == message_indices_from_ack(<<@packet_count_1::size(16), @min_is_max::size(8), 2::little-size(24)>>)
assert message_indices_from_ack(<<0, 1, 1, 0, 0, 0>>) == [0]
assert message_indices_from_ack(<<0, 1, 1, 1, 0, 0>>) == [1]
assert message_indices_from_ack(<<0, 1, 1, 3, 0, 0>>) == [3]
end
test "handles ack range" do
assert_lists_equal(
[1, 2, 3, 4],
message_indices_from_ack(<<@packet_count_1::size(16), @min_is_not_max::size(8), 1::little-size(24), 4::little-size(24)>>)
)
assert_lists_equal([1, 2], message_indices_from_ack(<<0, 1, 0, 1, 0, 0, 2, 0, 0>>))
end
test "handles many disjoint acks" do
assert_lists_equal(
[0, 4, 5, 6, 8, 9, 10, 11, 48],
message_indices_from_ack(<<
# Packet count
4::size(16),
# Drop 0
@min_is_max::size(8),
0::little-size(24),
# Drop 4, 5, 6
@min_is_not_max::size(8),
4::little-size(24),
6::little-size(24),
# Drop 8-11, inclusive
@min_is_not_max::size(8),
8::little-size(24),
11::little-size(24),
# Drop 48
@min_is_max::size(8),
48::little-size(24)
>>)
)
end
end
================================================
FILE: test/message_test.exs
================================================
defmodule RakNet.MessageTest do
use ExUnit.Case, async: true
test "lists the message name atoms" do
known_msg_names = RakNet.Message.known_message_names()
expected_atoms = MapSet.new([:ping, :unconnected_ping, :pong, :client_disconnect, :data_packet_8, :ack])
assert MapSet.subset?(expected_atoms, known_msg_names)
end
test "lists the message binary values" do
known_msgs = RakNet.Message.known_messages()
expected_vals = MapSet.new([0, 1, 2, 3, 5, 6, 7, 8, 9, 0x10, 0x13, 0x15, 0x1C, 0x1D, 0x80, 0x8F, 0xA0, 0xC0])
assert MapSet.subset?(expected_vals, known_msgs)
expected_missing = MapSet.new([4, 0x0A, 0x12, 0x14, 0x1B, 0x1E])
assert MapSet.disjoint?(known_msgs, expected_missing)
assert Enum.all?(Enum.map(expected_vals, &RakNet.Message.is_known?/1))
assert !Enum.any?(Enum.map(expected_missing, &RakNet.Message.is_known?/1))
end
test "fetches message name atoms from binary values" do
assert RakNet.Message.name(0) == :ping
assert RakNet.Message.name(0x13) == :client_handshake
assert RakNet.Message.name(0x13) == :client_handshake
assert RakNet.Message.name(0x14) == :error
assert RakNet.Message.name(0x1B) == :error
end
end
================================================
FILE: test/packet_test.exs
================================================
defmodule RakNet.PacketTest do
use ExUnit.Case
@test_data_packet <<25, 62, 69, 23, 124, 1, 0, 32, 0, 224, 105, 1, 0, 1, 0, 0, 0, 144, 9, 89, 211, 245, 50, 93, 224, 84, 166, 81, 195, 12,
94, 253, 127, 183, 0, 128, 255, 127, 255, 127, 195, 56, 128, 64, 0>>
@tag packet: true
test "decodes unreliable sequenced data packets" do
assert %{encapsulated_packets: [packet | []]} = RakNet.Packet.decode_with_timestamp(@test_data_packet)
assert packet.priority == 4
assert packet.reliability == :unreliable_sequenced
assert packet.length == 28
assert is_nil(packet.message_index)
assert not is_nil(packet.sequencing_index)
end
end
================================================
FILE: test/server_test.exs
================================================
defmodule RakNet.ServerTest do
use ExUnit.Case
require Logger
import RakNet.Packet, only: [decode_with_timestamp: 1, decode_no_timestamp: 1]
@moduledoc """
This is an acceptance test (in the sense popularized in the C++ community by Clare Macrae.
We run through the exact sequence of packets that an official RakNet sample app sends, and assert
that we send the correct responses.
"""
# I've hard-coded this as my GUID in my copy of the RakNet code, so that I can create reproducible packet tests
@fixed_id 12_345_678_901_234_567_890
@localhost {127, 0, 0, 1}
@localhost6 {0, 0, 0, 0, 0, 0, 0, 1}
test "handles client connection negotiation" do
{_server_pid, server_host_and_port} = start_server(49_101)
{client_send, client_send_padded} = make_client_send_fns(49_100, server_host_and_port)
# :open_connection_request_1 -> :open_connection_reply_1
open_conn_req_1_with_retries(client_send_padded, fn -> server_responded("0600ffff00fefefefefdfdfdfd12345678ab54a98ceb1f0ad20005d4") end)
# :open_connection_request_2 -> :open_connection_reply_2
client_send.("0700ffff00fefefefefdfdfdfd123456780480fffffebfcd05d400059bb99c3c475c")
assert server_responded("0800ffff00fefefefefdfdfdfd12345678ab54a98ceb1f0ad20480fffffebfcc05d400", normalize_ip_addresses: true),
"Failed to respond to open connection request 2"
# :data_packet_4[:client_connect] -> :data_packet_4[:server_handshake]
client_send.("840000004001080000000900059bb98e0d2f4a00000000000000160052756d70656c7374696c74736b696e")
assert server_responded_many([
{"8400000060030000000000000000100480fffffebfcc0000043f57fe32bfcd04ffffffff000004ffffffff000004ffffffff000004ffffffff000004ffffffff000004ffffffff000004ffffffff000004ffffffff000004ffffffff000000000000000000160000000000004345",
[
normalize_ip_addresses: true,
# Ignore the timestamp at the end
discard_last_bytes: 4
]},
{"c0000101000000", []}
]),
"Failed to respond to client_connect with handshake and/or ack client ordered packet 0"
# Have the client ack the handshake
client_send.("c0000101000000")
# :data_packet_4[client_handshake] requires a pong response from its ping
client_send.(
"840100006002f001000000000000130480fffffebfcd043f57fe32bfcc04ffffffff000004ffffffff000004ffffffff000004ffffffff000004ffffffff000004ffffffff000004ffffffff000004ffffffff000004ffffffff000000000000000040160000000000000025000048000000000000000025"
)
# :data_packet_4[ping]
client_send.("8402000000004800000000000000002f")
# :data_packet_4[ping, pong for 0x25, pong for 0x2f]
# "8401000000004800000000000000402d000088030000000000000025000000000000402d00008803000000000000002f000000000000402d",
assert server_sent_packets_with(2, [
{:ack, [1, 2]},
{:ping, :unreliable},
{:pong, :unreliable},
{:pong, :unreliable},
{:pong, :unreliable}
]),
"Failed to handle client handshake and additional enqueued pong"
# Ack the data packet with the ping and 2 pongs
client_send.("c0000101010000")
# 2 pongs to respond to our pings
client_send.("84030000000088030000000000003ca4000000000000002c000088030000000000003ca4000000000000002c")
assert server_responded("c0000101030000"), "Failed to respond to ack client's packet 3"
# Send an actual "business logic" packet---a chat message "ahoy"
client_send.("841a00006000280800000200000061686f7900")
# Note that the "a" gets dropped from our "ahoy" packet, because the first byte of the message is always the client packet type
assert receive_business_logic_msg() == "hoy" <> <<0>>, "Failed to receive 'ahoy' business logic message"
end
@tag xplane: true
test "handles client negotiation with embedded timestamps" do
has_embedded_timestamp = true
# Send timestamps with datagrams!
{_server_pid, server_host_and_port} = start_xplane_server(49_108)
{client_send, client_send_padded} = make_client_send_fns(49_109, server_host_and_port)
# :open_connection_request_1 -> :open_connection_reply_1
open_conn_req_1_with_retries(client_send_padded, fn -> server_sent_packet_with({:open_connection_reply_1, :connection_negotiation}) end)
# :open_connection_request_2 -> :open_connection_reply_2
client_send.("0700FFFF00FEFEFEFEFDFDFDFD12345678043F57FE07BB8005D422A2C5FD8AF0CF58")
assert server_sent_packet_with({:open_connection_reply_2, :connection_negotiation}), "Failed to respond to open connection request 2"
# :data_packet_4[:client_connect] -> :data_packet_4[:server_handshake]
client_send.("840001703F0000004000900000000922A2C5FD8AF0CF58000000000000005D00")
assert server_sent_packets_with(2, [{:server_handshake, :reliable_ordered}, {:ack, [0]}], true),
"Failed to respond to client_connect with handshake and/or ack client ordered packet 0"
# Have the client ack the handshake
client_send.("C000000020000101000000")
# :data_packet_4[client_handshake] requires a pong response from its ping
client_send.(
"84000216EE0100006019E00100000000000013043F57FE07BB80061C1EBF6800000000FE80000000000000C2A53EFFFE188D771F000000061C1EBF6800000000FE8000000000000018AB2FDACB39D7F202000000061C1EBF6800000000FE80000000000000CBAD3755438672141C000000061C1EBF68000000002607FC20E12A44EBE8F61BF2372CAB6500000000061C1EBF6800000000FE80000000000000DD135BB99B71EB3216000000061C1EBF68000000002607FC20E12A44EB10A1ABB78F934D9300000000061C1EBF6800000000FE800000000000004FD46183DE0250B315000000061C1EBF68000000002607FC20E12A44EBD143E3D178F8727800000000061C1EBF68000000002607FC20E12A44EBE497120B5A43099E00000000061C1EBF68000000002607FB901787903B319202F400D6266300000000061C1EBF6800000000FE8000000000000000381638AF01F1F703000000061C1EBF6800000000FE80000000000000590E28A7A8EE2D3514000000061C1EBF6800000000FE80000000000000397893EEA9E580A113000000049B69AB37BF68061C1EBF68000000002607FB901787903B043F38E29EE8C16000000000061C1EBF68000000002607FB901787903B097329AED1ACD39B00000000061C1EBF6800000000FE8000000000000004A607634B35B90308000000061C1EBF68000000002605A601AD7804000CE83F74A5CB264500000000061C1EBF6800000000FE80000000000000C2A53EFFFE188D770C000000061C1EBF6800000000FE80000000000000C017C6FFFE53F8660B000000061C1EBF6800000000FE80000000000000040018EFABB6CE8A0A000000061C1EBF6800000000FD7465726D6E7573000DA8FCEB6EE7BF00000000061C1EBF6800000000FE800000000000001AAA9D708BA12BB30D000000061C1EBF6800000000FD7465726D6E7573000CA8FCEB6EE7BF00000000061C1EBF68000000002607FB901787903B88E0C05F76E6D09900000000061C1EBF68000000002607FB901787903B802F37306E7ABD4200000000061C1EBF68000000002605A601AD780400A4F5EA8182BC7797000000000456018792BF68043F57FE35BF68061C1EBF6800000000FD7465726D6E7573000CA8FCEB6EE7BF000000000000000000000000000000000000007000004800000000000000007D"
)
# :data_packet_4[ping, pong for 0x25, pong for 0x2f]
# "8401000000004800000000000000402d000088030000000000000025000000000000402d00008803000000000000002f000000000000402d",
assert server_sent_packets_with(
2,
[
{:ack, [1]},
{:ping, :unreliable},
{:pong, :unreliable},
{:pong, :unreliable}
],
has_embedded_timestamp
),
"Failed to handle client handshake and additional enqueued pong"
end
@tag slow: true
test "resends unacknowledged packets" do
# ------------------------------------------------------------------------------------------------------------------
# BEGIN COPYPASTA
# Everything up to the Process.sleep() is copypasta from the connection negotiation test!
# ------------------------------------------------------------------------------------------------------------------
{_server_pid, server_host_and_port} = start_server(49_103)
{client_send, client_send_padded} = make_client_send_fns(49_102, server_host_and_port)
# :open_connection_request_1 -> :open_connection_reply_1
open_conn_req_1_with_retries(client_send_padded, fn -> server_sent_packet_with({:open_connection_reply_1, :connection_negotiation}) end)
# :open_connection_request_2 -> :open_connection_reply_2
client_send.("0700ffff00fefefefefdfdfdfd123456780480fffffebfcf05d400059bb99c3c475c")
assert server_sent_packet_with({:open_connection_reply_2, :connection_negotiation}), "Failed to respond to open connection request 2"
# :data_packet_4[:client_connect] -> :data_packet_4[:server_handshake]
client_send.("840000004001080000000900059bb98e0d2f4a00000000000000160052756d70656c7374696c74736b696e")
assert server_sent_packets_with(2, [{:server_handshake, :reliable_ordered}, {:ack, [0]}]),
"Failed to respond to client_connect with handshake and/or ack client ordered packet 0"
# ------------------------------------------------------------------------------------------------------------------
# END COPYPASTA
# ------------------------------------------------------------------------------------------------------------------
# Sleep while the client deliberately does *not* send an ack for the :server_handshake packet
Process.sleep(1100)
assert server_sent_packet_with({:server_handshake, :reliable_ordered}), "Failed to resend reliable packet :server_handshake"
Process.sleep(1100)
assert server_sent_packet_with({:server_handshake, :reliable_ordered}), "Failed to resend :server_handshake a second time"
# Ack the handshake (packet index 2, not 0, since reliable packet 0 got resent twice)
# TODO: Should we be keeping track of the packet's *old* index and allowing ack based on that? (0 in this case)
client_send.("c0000101020000")
receive do
{:protocol_msg, msg_raw} -> raise RuntimeError, message: "Server should not have sent any more messages; sent #{inspect(msg_raw)}"
after
1_100 -> :ok
end
end
@tag slow: true
test "connections time out after not hearing from the client" do
timeout_ms = 500
{_server_pid, server_host_and_port} = start_server(49_105, timeout_ms)
{_client_send, client_send_padded} = make_client_send_fns(49_104, server_host_and_port)
# :open_connection_request_1 -> :open_connection_reply_1
open_conn_req_1_with_retries(client_send_padded, fn -> server_sent_packet_with({:open_connection_reply_1, :connection_negotiation}) end)
:timer.sleep(timeout_ms + 100)
matching_pids = Registry.lookup(RakNet.Connection, {@localhost, 49_104})
if not Enum.empty?(matching_pids) do
[{_, pid} | _] = matching_pids
if Process.alive?(pid) do
# Flush all messages on the process
:sys.get_state(pid)
end
assert not Process.alive?(pid)
end
end
defmodule AckRequestingClient do
@enforce_keys [:handle_data, :handle_ack, :connection_pid]
defstruct handle_data: nil, handle_ack: nil, connection_pid: nil
def new(connection_pid, test_pid) do
:timer.apply_interval(100, AckRequestingClient, :bug_the_user, [connection_pid])
%AckRequestingClient{
connection_pid: connection_pid,
handle_data: fn _packet_type, data -> send(test_pid, {:business_logic_msg, data}) end,
handle_ack: fn send_receipt_id -> send(test_pid, {:business_logic_ack, send_receipt_id}) end
}
end
def bug_the_user(connection_pid) do
RakNet.Connection.send(connection_pid, :reliable_ack_receipt, "please acknowledge")
end
end
defimpl RakNet.Client, for: AckRequestingClient do
def new(_client_struct, connection_pid, test_pid), do: AckRequestingClient.new(connection_pid, test_pid)
def receive(client, packet_type, packet_buffer, _time_comp), do: client.handle_data.(packet_type, packet_buffer)
def got_ack(client, send_receipt_id), do: client.handle_ack.(send_receipt_id)
def disconnect(_client), do: :ok
end
test "forwards acks to game logic client" do
# ------------------------------------------------------------------------------------------------------------------
# BEGIN COPYPASTA
# Everything up to the Process.sleep() is copypasta from the connection negotiation test!
# ------------------------------------------------------------------------------------------------------------------
{_server_pid, server_host_and_port} = start_server(49_201, 1_000, AckRequestingClient)
{client_send, client_send_padded} = make_client_send_fns(49_200, server_host_and_port)
# :open_connection_request_1 -> :open_connection_reply_1
open_conn_req_1_with_retries(client_send_padded, fn -> server_sent_packet_with({:open_connection_reply_1, :connection_negotiation}) end)
# :open_connection_request_2 -> :open_connection_reply_2
client_send.("0700ffff00fefefefefdfdfdfd123456780480fffffebfcd05d400059bb99c3c475c")
assert server_sent_packet_with({:open_connection_reply_2, :connection_negotiation}), "Failed to respond to open connection request 2"
# :data_packet_4[:client_connect] -> :data_packet_4[:server_handshake]
client_send.("840000004001080000000900059bb98e0d2f4a00000000000000160052756d70656c7374696c74736b696e")
assert server_sent_packets_with(2, [{:server_handshake, :reliable_ordered}, {:ack, [0]}]),
"Failed to respond to client_connect with handshake and/or ack client ordered packet 0"
# Have the client ack the handshake
client_send.("c0000101000000")
# :data_packet_4[client_handshake] requires a pong response from its ping
client_send.(
"840100006002f001000000000000130480fffffebfcd043f57fe32bfcc04ffffffff000004ffffffff000004ffffffff000004ffffffff000004ffffffff000004ffffffff000004ffffffff000004ffffffff000004ffffffff000000000000000040160000000000000025000048000000000000000025"
)
# :data_packet_4[ping]
client_send.("8402000000004800000000000000002f")
# :data_packet_4[ping, pong for 0x25, pong for 0x2f]
assert server_sent_packets_with(2, [
{:ack, [1, 2]},
{:ping, :unreliable},
{:pong, :unreliable},
{:pong, :unreliable},
{:pong, :unreliable}
]),
"Failed to handle client handshake and additional enqueued pong"
# Ack the data packet with the ping and 2 pongs
client_send.("c0000101010000")
# 2 pongs to respond to our pings
client_send.("84030000000088030000000000003ca4000000000000002c000088030000000000003ca4000000000000002c")
assert server_responded("c0000101030000"), "Failed to respond to ack client's packet 3"
# ------------------------------------------------------------------------------------------------------------------
# END COPYPASTA
# ------------------------------------------------------------------------------------------------------------------
# Have the server send a value that requests an ack
assert server_sent_packet_with({"please acknowledge", :reliable_ack_receipt})
client_send.("c0000101020000")
assert receive_business_logic_ack()
end
@tag ipv6: true
test "handles IPv6 connections" do
{_server_pid, _server_host_and_port} = start_server(49_211, 1_000, AckRequestingClient)
{client_send, client_send_padded} = make_client_send_fns(49_210, {@localhost6, 49_211}, 6)
# :open_connection_request_1 -> :open_connection_reply_1
open_conn_req_1_with_retries(client_send_padded, fn -> server_sent_packet_with({:open_connection_reply_1, :connection_negotiation}) end)
# :open_connection_request_2 -> :open_connection_reply_2
client_send.("0700ffff00fefefefefdfdfdfd123456780480fffffebfcd05d400059bb99c3c475c")
assert server_sent_packet_with({:open_connection_reply_2, :connection_negotiation}), "Failed to respond to open connection request 2"
# :data_packet_4[:client_connect] -> :data_packet_4[:server_handshake]
client_send.("840000004001080000000900059bb98e0d2f4a00000000000000160052756d70656c7374696c74736b696e")
assert server_sent_packets_with(2, [{:server_handshake, :reliable_ordered}, {:ack, [0]}]),
"Failed to respond to client_connect with handshake and/or ack client ordered packet 0"
# Have the client ack the handshake
client_send.("c0000101000000")
# :data_packet_4[client_handshake] requires a pong response from its ping
client_send.(
"840100006002f001000000000000130480fffffebfcd043f57fe32bfcc04ffffffff000004ffffffff000004ffffffff000004ffffffff000004ffffffff000004ffffffff000004ffffffff000004ffffffff000004ffffffff000000000000000040160000000000000025000048000000000000000025"
)
# :data_packet_4[ping]
client_send.("8402000000004800000000000000002f")
# :data_packet_4[ping, pong for 0x25, pong for 0x2f]
assert server_sent_packets_with(2, [
{:ack, [1, 2]},
{:ping, :unreliable},
{:pong, :unreliable},
{:pong, :unreliable},
{:pong, :unreliable}
]),
"Failed to handle client handshake and additional enqueued pong"
# Ack the data packet with the ping and 2 pongs
client_send.("c0000101010000")
# 2 pongs to respond to our pings
client_send.("84030000000088030000000000003ca4000000000000002c000088030000000000003ca4000000000000002c")
assert server_responded("c0000101030000"), "Failed to respond to ack client's packet 3"
# Have the server send a value that requests an ack
assert server_sent_packet_with({"please acknowledge", :reliable_ack_receipt})
client_send.("c0000101020000")
assert receive_business_logic_ack()
end
test "server complains if you send data packets without having negotiated a connection" do
{_server_pid, server_host_and_port} = start_server(49_107)
{client_send, client_send_padded} = make_client_send_fns(49_106, server_host_and_port)
# Attempt to ack some non-existent packet
client_send.("c0000101010000")
assert server_sent_packet_with({:connection_lost, :connection_negotiation}), "Server should nag to establish a connection before ack"
# Send a data packet out of the blue
client_send.("84030000000088030000000000003ca4000000000000002c000088030000000000003ca4000000000000002c")
assert server_sent_packet_with({:connection_lost, :connection_negotiation}), "Server should nag connect before sending data"
# Start opening a connection, but then send a data packet before completing the connection negotiation
open_conn_req_1_with_retries(client_send_padded, fn -> server_sent_packet_with({:open_connection_reply_1, :connection_negotiation}) end)
client_send.("841a00006000280800000200000061686f7900")
assert server_sent_packet_with({:connection_lost, :connection_negotiation}), "Server should nag to finish connection negotiation"
end
test "server survives connection crash" do
{server_pid, server_host_and_port} = start_server(49_900)
# Start a bunch of connections
[{live_client_send, port_to_keep_alive} | send_fns_and_ports_to_kill] =
Enum.map(1..10, fn i ->
port = 49_900 + i
{client_send, client_send_padded} = make_client_send_fns(port, server_host_and_port)
# :open_connection_request_1 -> :open_connection_reply_1
open_conn_req_1_with_retries(client_send_padded, fn ->
server_sent_packet_with({:open_connection_reply_1, :connection_negotiation})
end)
{client_send, port}
end)
ports_to_kill = Enum.map(send_fns_and_ports_to_kill, fn {_, port} -> port end)
# Kill all but one
Enum.each(ports_to_kill, fn port ->
[{_, pid}] = Registry.lookup(RakNet.Connection, {@localhost, port})
Process.exit(pid, :kill)
assert not Process.alive?(pid), "Failed to kill process for port #{port}"
end)
assert Process.alive?(server_pid), "Server died when we killed its connections"
[{_, live_pid}] = Registry.lookup(RakNet.Connection, {@localhost, port_to_keep_alive})
:sys.get_state(live_pid)
assert Process.alive?(live_pid), "PID died unexpectedly"
# :open_connection_request_2 -> :open_connection_reply_2
live_client_send.("0700ffff00fefefefefdfdfdfd123456780480fffffebfcd05d400059bb99c3c475c")
assert server_sent_packet_with({:open_connection_reply_2, :connection_negotiation}), "Failed to respond to open connection request 2"
assert Process.alive?(server_pid), "Server died when we killed its connections"
end
defmodule DummyClient do
defstruct handle_data: nil
def accept_data(client, packet_type, buffer) do
client.handle_data.(packet_type, buffer)
client
end
end
defimpl RakNet.Client, for: DummyClient do
def new(_, _, test_pid), do: %DummyClient{handle_data: fn _type, data -> send(test_pid, {:business_logic_msg, data}) end}
def receive(client, packet_type, packet_buffer, _time_comp), do: DummyClient.accept_data(client, packet_type, packet_buffer)
def got_ack(client, _send_receipt_id), do: client
def disconnect(_client), do: :ok
end
defp start_server(port, timeout_ms \\ 10_000, client_module \\ nil) do
test_pid = self()
{:ok, server_pid} =
RakNet.Server.start_link(if(client_module, do: client_module, else: DummyClient), port,
client_timeout_ms: timeout_ms,
client_data: test_pid,
server_identifier: <<@fixed_id::size(64)>>,
host: @localhost,
open_ipv6_socket: System.get_env("SEPARATE_IPV6_PORT") != "false",
send: fn _, packet, _client ->
send(test_pid, {:protocol_msg, packet})
end
)
{server_pid, {@localhost, port}}
end
defp start_xplane_server(port) do
test_pid = self()
{:ok, server_pid} =
RakNet.Server.start_link(DummyClient, port,
client_data: test_pid,
server_identifier: <<@fixed_id::size(64)>>,
host: @localhost,
send: fn _, packet, _client -> send(test_pid, {:protocol_msg, packet}) end,
# Begin X-Plane-specific #defines!
include_timestamp_with_datagrams: true,
max_number_of_internal_ids: 30
)
{server_pid, {@localhost, port}}
end
defp make_client_send_fns(client_port, server_host_and_port, ip_version \\ 4) do
socket =
if ip_version == 4 do
Socket.UDP.open!(client_port, local: [address: @localhost], version: 4)
else
Socket.UDP.open!(client_port, local: [address: @localhost6], version: 6)
end
client_send_padded = fn packet_string, pad_to_byte_length ->
data = packet_decode(packet_string)
pad_length = max(pad_to_byte_length * 8 - bit_size(data), 0)
Socket.Datagram.send!(socket, data <> <<0::size(pad_length)>>, server_host_and_port)
end
client_send = fn packet_string -> client_send_padded.(packet_string, 0) end
{client_send, client_send_padded}
end
defp packet_decode(packet_hex_string, pad_to_byte_length \\ 0) do
data = Base.decode16!(packet_hex_string, case: :mixed)
pad_length = max(pad_to_byte_length * 8 - bit_size(data), 0)
data <> <<0::size(pad_length)>>
end
defp server_responded(packet_hex_string, opts \\ []) when is_binary(packet_hex_string) do
server_responded_many([{packet_hex_string, opts}])
end
defp server_sent_packet_with(packet_spec, has_embedded_timestamp \\ false) do
server_sent_packets_with(1, [packet_spec], has_embedded_timestamp)
end
defp server_sent_packets_with(num_packets, packet_spec, has_embedded_timestamp \\ false) when is_list(packet_spec) do
actual_types_and_reliabilities =
num_packets
|> receive_n_sends()
|> Enum.flat_map(&parse_packet_type(&1, has_embedded_timestamp))
if Enum.sort(packet_spec) == Enum.sort(actual_types_and_reliabilities) do
true
else
Logger.error("Expected packets: #{inspect(packet_spec, limit: :infinity)}")
Logger.error("Received packets: #{inspect(actual_types_and_reliabilities, limit: :infinity)}")
false
end
end
# credo:disable-for-next-line
defp parse_packet_type(<>, has_embedded_timestamp) do
ack = RakNet.Message.binary(:ack)
decode_fn = if has_embedded_timestamp, do: &decode_with_timestamp/1, else: &decode_no_timestamp/1
ack_timestamp_bits = if has_embedded_timestamp, do: 32, else: 0
case type do
^ack ->
[{:ack, RakNet.Connection.message_indices_from_ack(drop_leading_bits(remainder, ack_timestamp_bits))}]
x when x in 0x80..0x8F ->
Enum.map(decode_fn.(remainder)[:encapsulated_packets], fn packet ->
<> = packet.buffer
case RakNet.Message.name(encapsulated_type) do
:error -> {packet.buffer, packet.reliability}
name -> {name, packet.reliability}
end
end)
x when x in 0x05..0x16 ->
[{RakNet.Message.name(type), :connection_negotiation}]
end
end
defp server_responded_many(packet_specs) when is_list(packet_specs) do
packets_and_xforms =
Enum.map(packet_specs, fn {packet_hex_string, opts} ->
# Replace loopback adapter's IP+port combo (which is what happens when you run the RakNet samples in the terminal)
# with "plain-old localhost" (which is what we get when we run the ExUnit test)
normalize_ips =
if Keyword.get(opts, :normalize_ip_addresses, false) do
fn bits ->
bits
|> String.replace(<<128, 255, 255, 254, 191, 204>>, <<127, 0, 0, 1, 191, 204>>)
|> String.replace(<<128, 255, 255, 254, 191, 206>>, <<127, 0, 0, 1, 191, 206>>)
|> String.replace(<<63, 87, 254, 50, 191, 205>>, <<127, 0, 0, 1, 191, 205>>)
|> String.replace(<<63, 87, 254, 50, 191, 207>>, <<127, 0, 0, 1, 191, 207>>)
end
else
& &1
end
drop_trailing = fn bits ->
new_length_bytes = byte_size(bits) - Keyword.get(opts, :discard_last_bytes, 0)
if new_length_bytes > 2, do: <>, else: bits
end
drop_bits = Keyword.get(opts, :discard_first_bytes, 0) * 8
drop_leading = &drop_leading_bits(&1, drop_bits)
transform = fn bits -> bits |> drop_leading.() |> drop_trailing.() |> normalize_ips.() end
expected_raw = packet_decode(packet_hex_string, Keyword.get(opts, :pad_to_byte_length, 0))
{expected_raw, transform.(expected_raw), transform}
end)
reduce_remove_nil = fn x, acc -> if x, do: [x | acc], else: acc end
msgs_raw = receive_n_sends(length(packets_and_xforms))
missing_expected =
packets_and_xforms
|> Enum.map(fn {raw, expected, transform} ->
has_match = Enum.any?(msgs_raw, fn msg_raw -> transform.(msg_raw) == expected end)
if has_match, do: nil, else: raw
end)
|> Enum.reduce([], reduce_remove_nil)
if length(missing_expected) > 0 do
Logger.error("Failed to receive expected message(s):")
Enum.each(missing_expected, fn msg -> Logger.error("#{inspect(msg, limit: :infinity)}") end)
end
unexpected_msgs =
msgs_raw
|> Enum.map(fn msg_raw ->
matched = Enum.any?(packets_and_xforms, fn {_raw, expected, transform} -> transform.(msg_raw) == expected end)
if matched, do: nil, else: msg_raw
end)
|> Enum.reduce([], reduce_remove_nil)
if length(unexpected_msgs) > 0 do
Logger.error("Received unexpected message(s):")
Enum.each(unexpected_msgs, fn msg -> Logger.error("#{inspect(msg, limit: :infinity)}") end)
end
Enum.empty?(unexpected_msgs) and Enum.empty?(missing_expected)
end
defp drop_leading_bits(data, num_bits) do
<<_::size(num_bits), remainder::binary>> = data
remainder
end
# With enough tests going at once, we can overwhelm the network interface---it's okay if we have to try this again
defp open_conn_req_1_with_retries(client_send_padded, success_check, max_attempts \\ 10, attempt \\ 1)
defp open_conn_req_1_with_retries(client_send_padded, success_check, max_attempts, attempt) when max_attempts > attempt do
client_send_padded.("0500ffff00fefefefefdfdfdfd1234567806", 1464)
assert success_check.()
rescue
_ ->
Process.sleep(250 + :rand.uniform(1250))
open_conn_req_1_with_retries(client_send_padded, success_check, max_attempts, attempt + 1)
end
defp open_conn_req_1_with_retries(_client_send_padded, _success_check, _max_attempts, _attempt) do
raise RuntimeError, message: "Failed to receive open_connection_reply_1 from our open_connection_request_1"
end
def receive_n_sends(n) when is_integer(n) do
Enum.map(1..n, fn _ ->
receive do
{:protocol_msg, msg_raw} -> msg_raw
after
1_000 ->
raise RuntimeError, message: "Didn't receive enough messages"
end
end)
end
def receive_business_logic_msg do
receive do
{:business_logic_msg, msg_raw} -> msg_raw
after
5_000 ->
raise RuntimeError, message: "Didn't receive a business logic message"
end
end
def receive_business_logic_ack do
receive do
{:business_logic_ack, msg_raw} -> msg_raw
after
5_000 ->
raise RuntimeError, message: "Didn't receive a business logic ack"
end
end
end
================================================
FILE: test/system_address_test.exs
================================================
defmodule RakNet.SystemAddressTest do
use ExUnit.Case, async: true
import Assertions, only: [assert_lists_equal: 2]
test "decodes IPv4 addresses" do
assert_lists_equal(RakNet.SystemAddress.decode_many(<<4, 63, 87, 254, 7, 187, 128, 4, 63, 87, 254, 53, 191, 104>>), [
%{version: 4, address: {63, 87, 254, 7}, port: 48_000},
%{version: 4, address: {63, 87, 254, 53}, port: 49_000}
])
end
test "decodes IPv6 addresses" do
# v6 size fam port flow info address------------------------------------------------------- scope
ipv6_1 = <<6, 28, 30, 191, 104, 0, 0, 0, 0, 254, 128, 0, 0, 0, 0, 0, 0, 4, 0, 24, 239, 171, 182, 206, 138, 10, 0, 0, 0>>
ipv6_2 = <<6, 28, 30, 191, 105, 0, 0, 0, 0, 38, 7, 251, 144, 23, 135, 144, 59, 213, 146, 204, 243, 194, 122, 129, 248, 0, 0, 0, 0>>
assert_lists_equal(RakNet.SystemAddress.decode_many(ipv6_1 <> ipv6_2), [
%{version: 6, address: {65_152, 0, 0, 0, 1024, 6383, 43_958, 52_874}, port: 49_000},
%{version: 6, address: {9735, 64_400, 6023, 36_923, 54_674, 52_467, 49_786, 33_272}, port: 49_001}
])
end
test "decodes a mix of IPv4 and IPv6 addresses" do
ipv4_1 = <<4, 63, 87, 254, 7, 187, 128>>
ipv4_2 = <<4, 63, 87, 254, 53, 191, 104>>
ipv6_1 = <<6, 28, 30, 191, 104, 0, 0, 0, 0, 254, 128, 0, 0, 0, 0, 0, 0, 4, 0, 24, 239, 171, 182, 206, 138, 10, 0, 0, 0>>
ipv6_2 = <<6, 28, 30, 191, 105, 0, 0, 0, 0, 38, 7, 251, 144, 23, 135, 144, 59, 213, 146, 204, 243, 194, 122, 129, 248, 0, 0, 0, 0>>
assert_lists_equal(RakNet.SystemAddress.decode_many(ipv6_1 <> ipv4_1 <> ipv6_2 <> ipv4_2), [
%{version: 4, address: {63, 87, 254, 7}, port: 48_000},
%{version: 4, address: {63, 87, 254, 53}, port: 49_000},
%{version: 6, address: {65_152, 0, 0, 0, 1024, 6383, 43_958, 52_874}, port: 49_000},
%{version: 6, address: {9735, 64_400, 6023, 36_923, 54_674, 52_467, 49_786, 33_272}, port: 49_001}
])
assert_lists_equal(
RakNet.SystemAddress.decode_many(ipv6_1 <> ipv4_1 <> ipv6_2 <> ipv4_2),
RakNet.SystemAddress.decode_many(ipv4_1 <> ipv6_1 <> ipv4_2 <> ipv6_2)
)
assert_lists_equal(
RakNet.SystemAddress.decode_many(ipv6_1 <> ipv4_1 <> ipv6_2 <> ipv4_2),
RakNet.SystemAddress.decode_many(ipv6_2 <> ipv6_1 <> ipv4_2 <> ipv4_1)
)
end
end
================================================
FILE: test/test_helper.exs
================================================
ExUnit.start()