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**: [![main build status](https://circleci.com/gh/X-Plane/elixir-raknet/tree/main.svg?style=svg)](https://circleci.com/gh/X-Plane/elixir-raknet/tree/main) **Latest commit build status**: [![Last commit build status](https://circleci.com/gh/X-Plane/elixir-raknet.svg?style=svg)](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()