Module: Doom::Net::Protocol
- Defined in:
- lib/doom/net/protocol.rb
Overview
Wire format for both multiplayer stacks: the peer-to-peer lockstep mesh (HELLO/SETUP/TICCMD/HASH/QUIT/PEERS) and the authoritative star server (WELCOME/SNAPSHOT/INPUT/FRAME).
Kept separate from the socket so it can be tested without one, and so nothing that parses bytes off the network has any business touching a file descriptor.
Every decode path is defensive: these bytes come from the network, and a truncated or malformed packet must produce nil, never an exception. Anything unrecognised is simply dropped.
Constant Summary collapse
- VERSION =
1- HELLO =
I would like to join
1- SETUP =
Here is the game you are joining, and who you are
2- TICCMD =
Input for one or more tics
3- HASH =
State fingerprint for a checkpoint tic
4- QUIT =
I am leaving
5- PEERS =
Everyone else's address, so the mesh can form
6- WELCOME =
Star-topology server packets (Net::GameServer). Separate from the peer lockstep ones above: the server is authoritative and never waits, so its packets carry finalized tics, not proposals.
7- SNAPSHOT =
server -> joiner: your id and the game, snapshot to follow
8- INPUT =
server -> joiner: one chunk of the world snapshot
9- FRAME =
client -> server: my ticcmd for a future tic
10- MAX_CMDS_PER_PACKET =
server -> clients: the finalized tics everyone must run
32- SNAPSHOT_CHUNK_BYTES =
Payload per snapshot chunk, kept well under a typical path MTU so a chunk is never itself IP-fragmented.
1024- MAX_FRAME_BYTES =
Ceiling for a FRAME packet. A frame carries every player's command, and FRAME_REDUNDANCY frames are repeated per packet against loss, so at 30 players the packet approaches the ~1500-byte mobile MTU and past it IP fragments -- and the receiver's fixed recv buffer would silently truncate it. Kept under the IPv6 minimum MTU (1280) minus headers so a FRAME never fragments; encode_frame_capped drops the oldest (most-redundant) frames to fit, always keeping at least the newest.
1200- MODES =
{ 0 => :coop, 1 => :deathmatch }.freeze
Class Method Summary collapse
- .decode(bytes) ⇒ Object
- .decode_frame(bytes) ⇒ Object
- .decode_hash(bytes) ⇒ Object
- .decode_hello(_bytes) ⇒ Object
- .decode_input(bytes) ⇒ Object
- .decode_peers(bytes) ⇒ Object
- .decode_quit(bytes) ⇒ Object
- .decode_setup(bytes) ⇒ Object
- .decode_snapshot_chunk(bytes) ⇒ Object
- .decode_string(bytes, offset) ⇒ Object
- .decode_ticcmds(bytes) ⇒ Object
- .decode_welcome(bytes) ⇒ Object
-
.encode_frame(frames) ⇒ Object
server -> clients.
-
.encode_frame_capped(frames, max_bytes: MAX_FRAME_BYTES) ⇒ Object
Encode the newest frames that fit under
max_bytes, dropping the oldest (whose only job is redundancy) until the packet fits. - .encode_hash(tic, player_id, sections) ⇒ Object
-
.encode_hello ⇒ Object
HELLO carries nothing but "let me in": the server (or host) assigns the id and tells the joiner everything else.
-
.encode_input(player_id, pairs) ⇒ Object
client -> server.
-
.encode_peers(entries) ⇒ Object
entriesis [[player_id, host, port], ...]. - .encode_quit(player_id) ⇒ Object
- .encode_setup(player_id:, num_players:, seed:, map:, mode:, skill:) ⇒ Object
- .encode_snapshot_chunk(snapshot_tic, index, total, chunk_bytes) ⇒ Object
-
.encode_string(str) ⇒ Object
Length-prefixed so a name or map with odd bytes cannot run off the end.
-
.encode_ticcmds(player_id, pairs, ack: 0) ⇒ Object
pairsis [[tic, Ticcmd], ...]. -
.encode_welcome(player_id:, num_players:, mode:, skill:, snapshot_tic:, map:) ⇒ Object
server -> joiner.
- .mode_code(mode) ⇒ Object
- .mode_name(code) ⇒ Object
-
.snapshot_chunks(snapshot_tic, bytes) ⇒ Object
Split a full snapshot into datagram-sized chunks for one tic.
Class Method Details
.decode(bytes) ⇒ Object
153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 |
# File 'lib/doom/net/protocol.rb', line 153 def decode(bytes) return nil if bytes.nil? || bytes.bytesize < 2 bytes = bytes.b version, type = bytes.unpack('CC') return nil unless version == VERSION case type when HELLO then decode_hello(bytes) when SETUP then decode_setup(bytes) when TICCMD then decode_ticcmds(bytes) when HASH then decode_hash(bytes) when QUIT then decode_quit(bytes) when PEERS then decode_peers(bytes) when WELCOME then decode_welcome(bytes) when SNAPSHOT then decode_snapshot_chunk(bytes) when INPUT then decode_input(bytes) when FRAME then decode_frame(bytes) end end |
.decode_frame(bytes) ⇒ Object
208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 |
# File 'lib/doom/net/protocol.rb', line 208 def decode_frame(bytes) return nil if bytes.bytesize < 3 count = bytes.unpack('CCC')[2] offset = 3 frames = [] entry = 1 + Game::Ticcmd::PACKED_SIZE count.times do return nil if bytes.bytesize < offset + 5 tic = bytes[offset, 4].unpack1('L<') offset += 4 njoins = bytes[offset].unpack1('C') offset += 1 return nil if bytes.bytesize < offset + njoins joins = bytes[offset, njoins].unpack('C*') offset += njoins return nil if bytes.bytesize < offset + 1 nleaves = bytes[offset].unpack1('C') offset += 1 return nil if bytes.bytesize < offset + nleaves leaves = bytes[offset, nleaves].unpack('C*') offset += nleaves return nil if bytes.bytesize < offset + 1 ncmds = bytes[offset].unpack1('C') offset += 1 return nil if bytes.bytesize < offset + (ncmds * entry) cmds = Array.new(ncmds) do |i| at = offset + (i * entry) [bytes[at].unpack1('C'), Game::Ticcmd.unpack(bytes[at + 1, Game::Ticcmd::PACKED_SIZE])] end offset += ncmds * entry frames << { tic: tic, joins: joins, leaves: leaves, cmds: cmds } end { type: FRAME, frames: frames } end |
.decode_hash(bytes) ⇒ Object
306 307 308 309 310 311 312 313 314 315 316 |
# File 'lib/doom/net/protocol.rb', line 306 def decode_hash(bytes) names = Game::StateHash::SECTIONS return nil if bytes.bytesize < 7 + (names.size * 4) _, _, player_id = bytes.unpack('CCC') tic = bytes[3, 4].unpack1('L<') values = bytes[7, names.size * 4].unpack('L<*') { type: HASH, player_id: player_id, tic: tic, sections: names.zip(values).to_h } end |
.decode_hello(_bytes) ⇒ Object
274 275 276 |
# File 'lib/doom/net/protocol.rb', line 274 def decode_hello(_bytes) { type: HELLO } end |
.decode_input(bytes) ⇒ Object
194 195 196 197 198 199 200 201 202 203 204 205 206 |
# File 'lib/doom/net/protocol.rb', line 194 def decode_input(bytes) return nil if bytes.bytesize < 4 _, _, player_id, count = bytes.unpack('CCCC') entry = 4 + Game::Ticcmd::PACKED_SIZE return nil if bytes.bytesize < 4 + (count * entry) cmds = Array.new(count) do |i| at = 4 + (i * entry) [bytes[at, 4].unpack1('L<'), Game::Ticcmd.unpack(bytes[at + 4, Game::Ticcmd::PACKED_SIZE])] end { type: INPUT, player_id: player_id, cmds: cmds } end |
.decode_peers(bytes) ⇒ Object
252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 |
# File 'lib/doom/net/protocol.rb', line 252 def decode_peers(bytes) return nil if bytes.bytesize < 3 count = bytes.unpack('CCC')[2] offset = 3 entries = [] count.times do return nil if bytes.bytesize < offset + 1 player_id = bytes[offset].unpack1('C') host, offset = decode_string(bytes, offset + 1) return nil if host.nil? return nil if bytes.bytesize < offset + 2 entries << [player_id, host, bytes[offset, 2].unpack1('S<')] offset += 2 end { type: PEERS, peers: entries } end |
.decode_quit(bytes) ⇒ Object
318 319 320 321 322 |
# File 'lib/doom/net/protocol.rb', line 318 def decode_quit(bytes) return nil if bytes.bytesize < 3 { type: QUIT, player_id: bytes.unpack('CCC')[2] } end |
.decode_setup(bytes) ⇒ Object
278 279 280 281 282 283 284 285 286 287 |
# File 'lib/doom/net/protocol.rb', line 278 def decode_setup(bytes) return nil if bytes.bytesize < 7 _, _, player_id, num_players, seed, mode, skill = bytes.unpack('CCCCCCC') map, = decode_string(bytes, 7) return nil if map.nil? || map.empty? { type: SETUP, player_id: player_id, num_players: num_players, seed: seed, mode: mode_name(mode), skill: skill, map: map } end |
.decode_snapshot_chunk(bytes) ⇒ Object
186 187 188 189 190 191 192 |
# File 'lib/doom/net/protocol.rb', line 186 def decode_snapshot_chunk(bytes) return nil if bytes.bytesize < 10 snapshot_tic, index, total = bytes[2, 8].unpack('L<S<S<') { type: SNAPSHOT, snapshot_tic: snapshot_tic, index: index, total: total, chunk: bytes.byteslice(10, bytes.bytesize - 10) } end |
.decode_string(bytes, offset) ⇒ Object
330 331 332 333 334 335 336 337 |
# File 'lib/doom/net/protocol.rb', line 330 def decode_string(bytes, offset) return [nil, offset] if bytes.bytesize < offset + 1 len = bytes[offset].unpack1('C') return [nil, offset] if bytes.bytesize < offset + 1 + len [bytes[offset + 1, len], offset + 1 + len] end |
.decode_ticcmds(bytes) ⇒ Object
289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 |
# File 'lib/doom/net/protocol.rb', line 289 def decode_ticcmds(bytes) return nil if bytes.bytesize < 8 _, _, player_id, count = bytes.unpack('CCCC') ack = bytes[4, 4].unpack1('L<') entry = 4 + Game::Ticcmd::PACKED_SIZE return nil if bytes.bytesize < 8 + (count * entry) cmds = Array.new(count) do |i| at = 8 + (i * entry) tic = bytes[at, 4].unpack1('L<') [tic, Game::Ticcmd.unpack(bytes[at + 4, Game::Ticcmd::PACKED_SIZE])] end { type: TICCMD, player_id: player_id, ack: ack, cmds: cmds } end |
.decode_welcome(bytes) ⇒ Object
174 175 176 177 178 179 180 181 182 183 184 |
# File 'lib/doom/net/protocol.rb', line 174 def decode_welcome(bytes) return nil if bytes.bytesize < 10 _, _, player_id, num_players, mode, skill = bytes.unpack('CCCCCC') snapshot_tic = bytes[6, 4].unpack1('L<') map, = decode_string(bytes, 10) return nil if map.nil? || map.empty? { type: WELCOME, player_id: player_id, num_players: num_players, mode: mode_name(mode), skill: skill, snapshot_tic: snapshot_tic, map: map } end |
.encode_frame(frames) ⇒ Object
server -> clients. A batch of finalized tics, oldest first. Each tic carries the membership changes to apply (joins and leaves are tic events, because a deathmatch spawn draws the shared RNG and must happen on the same tic everywhere) and then every player's command.
127 128 129 130 131 132 133 134 135 136 137 |
# File 'lib/doom/net/protocol.rb', line 127 def encode_frame(frames) frames = frames.last(MAX_CMDS_PER_PACKET) body = frames.map do |f| [f[:tic]].pack('L<') + [f[:joins].size].pack('C') + f[:joins].pack('C*') + [f[:leaves].size].pack('C') + f[:leaves].pack('C*') + [f[:cmds].size].pack('C') + f[:cmds].map { |id, cmd| [id].pack('C') + cmd.pack }.join end.join [VERSION, FRAME, frames.size].pack('CCC') + body end |
.encode_frame_capped(frames, max_bytes: MAX_FRAME_BYTES) ⇒ Object
Encode the newest frames that fit under max_bytes, dropping the oldest
(whose only job is redundancy) until the packet fits. Always keeps at
least the newest frame, so a client can still advance even when a single
frame at a huge player count is itself over budget.
143 144 145 146 147 148 149 150 151 |
# File 'lib/doom/net/protocol.rb', line 143 def encode_frame_capped(frames, max_bytes: MAX_FRAME_BYTES) selected = frames loop do packet = encode_frame(selected) return packet if packet.bytesize <= max_bytes || selected.size <= 1 selected = selected[1..] # drop the oldest, retry end end |
.encode_hash(tic, player_id, sections) ⇒ Object
74 75 76 77 |
# File 'lib/doom/net/protocol.rb', line 74 def encode_hash(tic, player_id, sections) values = Game::StateHash::SECTIONS.map { |name| sections[name].to_i } [VERSION, HASH, player_id].pack('CCC') + [tic].pack('L<') + values.pack('L<*') end |
.encode_hello ⇒ Object
HELLO carries nothing but "let me in": the server (or host) assigns the id and tells the joiner everything else.
51 52 53 |
# File 'lib/doom/net/protocol.rb', line 51 def encode_hello [VERSION, HELLO].pack('CC') end |
.encode_input(player_id, pairs) ⇒ Object
client -> server. pairs is [[tic, Ticcmd], ...]; recent inputs are
repeated so a lost packet is covered by the next, as with TICCMD.
117 118 119 120 121 |
# File 'lib/doom/net/protocol.rb', line 117 def encode_input(player_id, pairs) pairs = pairs.last(MAX_CMDS_PER_PACKET) body = pairs.map { |tic, cmd| [tic].pack('L<') + cmd.pack }.join [VERSION, INPUT, player_id, pairs.size].pack('CCCC') + body end |
.encode_peers(entries) ⇒ Object
entries is [[player_id, host, port], ...]. The host sends this so
clients can talk to each other directly instead of relaying everything
through it, which would add a hop of latency to every command.
86 87 88 89 90 91 |
# File 'lib/doom/net/protocol.rb', line 86 def encode_peers(entries) body = entries.map do |player_id, host, port| [player_id].pack('C') + encode_string(host) + [port].pack('S<') end.join [VERSION, PEERS, entries.size].pack('CCC') + body end |
.encode_quit(player_id) ⇒ Object
79 80 81 |
# File 'lib/doom/net/protocol.rb', line 79 def encode_quit(player_id) [VERSION, QUIT, player_id].pack('CCC') end |
.encode_setup(player_id:, num_players:, seed:, map:, mode:, skill:) ⇒ Object
55 56 57 58 |
# File 'lib/doom/net/protocol.rb', line 55 def encode_setup(player_id:, num_players:, seed:, map:, mode:, skill:) [VERSION, SETUP, player_id, num_players, seed, mode_code(mode), skill].pack('CCCCCCC') + encode_string(map) end |
.encode_snapshot_chunk(snapshot_tic, index, total, chunk_bytes) ⇒ Object
101 102 103 |
# File 'lib/doom/net/protocol.rb', line 101 def encode_snapshot_chunk(snapshot_tic, index, total, chunk_bytes) [VERSION, SNAPSHOT].pack('CC') + [snapshot_tic, index, total].pack('L<S<S<') + chunk_bytes.b end |
.encode_string(str) ⇒ Object
Length-prefixed so a name or map with odd bytes cannot run off the end.
325 326 327 328 |
# File 'lib/doom/net/protocol.rb', line 325 def encode_string(str) s = str.to_s.b[0, 255] [s.bytesize].pack('C') + s end |
.encode_ticcmds(player_id, pairs, ack: 0) ⇒ Object
pairs is [[tic, Ticcmd], ...]. Packets carry several commands so a
lost one is covered by the next packet: a ticcmd is 7 bytes, far
cheaper to repeat than to request again over a round trip.
ack is the lowest tic the sender is still waiting for. Peers resend
from there, so a command cannot be lost for good by falling out of a
fixed-size window -- with real packet loss that eventually deadlocks
both sides, since lockstep never skips a tic.
68 69 70 71 72 |
# File 'lib/doom/net/protocol.rb', line 68 def encode_ticcmds(player_id, pairs, ack: 0) pairs = pairs.last(MAX_CMDS_PER_PACKET) body = pairs.map { |tic, cmd| [tic].pack('L<') + cmd.pack }.join [VERSION, TICCMD, player_id, pairs.size].pack('CCCC') + [ack].pack('L<') + body end |
.encode_welcome(player_id:, num_players:, mode:, skill:, snapshot_tic:, map:) ⇒ Object
server -> joiner. Says who they are and what game this is; the world itself follows as SNAPSHOT chunks for the same snapshot_tic. No RNG seed is sent: the snapshot already carries the exact RNG state to resume from.
96 97 98 99 |
# File 'lib/doom/net/protocol.rb', line 96 def encode_welcome(player_id:, num_players:, mode:, skill:, snapshot_tic:, map:) [VERSION, WELCOME, player_id, num_players, mode_code(mode), skill].pack('CCCCCC') + [snapshot_tic].pack('L<') + encode_string(map) end |
.mode_code(mode) ⇒ Object
341 342 343 |
# File 'lib/doom/net/protocol.rb', line 341 def mode_code(mode) MODES.key(mode.to_sym) || 0 end |
.mode_name(code) ⇒ Object
345 346 347 |
# File 'lib/doom/net/protocol.rb', line 345 def mode_name(code) MODES.fetch(code, :coop) end |
.snapshot_chunks(snapshot_tic, bytes) ⇒ Object
Split a full snapshot into datagram-sized chunks for one tic.
106 107 108 109 110 111 112 113 |
# File 'lib/doom/net/protocol.rb', line 106 def snapshot_chunks(snapshot_tic, bytes) bytes = bytes.b total = [(bytes.bytesize + SNAPSHOT_CHUNK_BYTES - 1) / SNAPSHOT_CHUNK_BYTES, 1].max (0...total).map do |i| encode_snapshot_chunk(snapshot_tic, i, total, bytes.byteslice(i * SNAPSHOT_CHUNK_BYTES, SNAPSHOT_CHUNK_BYTES) || ''.b) end end |