doom 0.8.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +17 -0
  3. data/README.md +65 -4
  4. data/bin/doom +134 -24
  5. data/lib/doom/benchmark.rb +282 -0
  6. data/lib/doom/game/animations.rb +9 -2
  7. data/lib/doom/game/combat.rb +239 -153
  8. data/lib/doom/game/framebuffer_blitter.rb +38 -0
  9. data/lib/doom/game/geometry.rb +68 -0
  10. data/lib/doom/game/intermission.rb +22 -39
  11. data/lib/doom/game/item_pickup.rb +87 -75
  12. data/lib/doom/game/menu.rb +90 -85
  13. data/lib/doom/game/monster_ai.rb +150 -136
  14. data/lib/doom/game/player.rb +105 -0
  15. data/lib/doom/game/player_physics.rb +384 -0
  16. data/lib/doom/game/player_state.rb +38 -64
  17. data/lib/doom/game/random.rb +82 -0
  18. data/lib/doom/game/sector_actions.rb +169 -93
  19. data/lib/doom/game/sector_effects.rb +25 -18
  20. data/lib/doom/game/snapshot.rb +584 -0
  21. data/lib/doom/game/sound_engine.rb +33 -58
  22. data/lib/doom/game/state_hash.rb +125 -0
  23. data/lib/doom/game/ticcmd.rb +61 -0
  24. data/lib/doom/game/world.rb +420 -0
  25. data/lib/doom/map/data.rb +95 -0
  26. data/lib/doom/net/client.rb +204 -0
  27. data/lib/doom/net/desync_monitor.rb +101 -0
  28. data/lib/doom/net/game_server.rb +232 -0
  29. data/lib/doom/net/lockstep.rb +170 -0
  30. data/lib/doom/net/protocol.rb +350 -0
  31. data/lib/doom/net/session.rb +282 -0
  32. data/lib/doom/net/transport.rb +110 -0
  33. data/lib/doom/platform/gosu_window.rb +604 -832
  34. data/lib/doom/platform/sdl.rb +74 -0
  35. data/lib/doom/platform/window_logic.rb +61 -0
  36. data/lib/doom/render/font.rb +5 -3
  37. data/lib/doom/render/hardware_renderer.rb +547 -0
  38. data/lib/doom/render/ray_tracing/bvh.rb +103 -0
  39. data/lib/doom/render/ray_tracing/material_state.rb +66 -0
  40. data/lib/doom/render/ray_tracing/texture_atlas.rb +78 -0
  41. data/lib/doom/render/ray_tracing_renderer.rb +940 -0
  42. data/lib/doom/render/renderer.rb +466 -440
  43. data/lib/doom/render/renderer_factory.rb +50 -0
  44. data/lib/doom/render/screen_melt.rb +7 -7
  45. data/lib/doom/render/spinel_native/kernel.rb +780 -0
  46. data/lib/doom/render/spinel_native_renderer.rb +162 -0
  47. data/lib/doom/render/status_bar.rb +14 -10
  48. data/lib/doom/render/weapon_renderer.rb +9 -10
  49. data/lib/doom/render/world_mesh.rb +270 -0
  50. data/lib/doom/render/zbuffer_renderer.rb +151 -0
  51. data/lib/doom/version.rb +1 -1
  52. data/lib/doom/wad/flat.rb +1 -1
  53. data/lib/doom/wad/hud_graphics.rb +7 -3
  54. data/lib/doom/wad/palette.rb +3 -3
  55. data/lib/doom/wad/patch.rb +1 -1
  56. data/lib/doom/wad/reader.rb +12 -15
  57. data/lib/doom/wad/sound.rb +14 -6
  58. data/lib/doom/wad/sprite.rb +36 -35
  59. data/lib/doom/wad/texture.rb +5 -5
  60. data/lib/doom/wad_downloader.rb +38 -56
  61. data/lib/doom.rb +197 -16
  62. metadata +94 -7
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doom
4
+ module Net
5
+ # Lockstep tic scheduling, with no socket attached.
6
+ #
7
+ # Every peer simulates every player. Only ticcmds travel, and a tic runs
8
+ # only once the commands of all players for that tic are in hand. Two peers
9
+ # feeding equal commands to equal worlds stay equal; nothing else needs to
10
+ # be synchronised.
11
+ #
12
+ # Local input is scheduled `delay` tics into the future, which is what buys
13
+ # the network time to deliver it: input sampled now is consumed a few tics
14
+ # from now, by which point everyone has it. The first `delay` tics run on
15
+ # neutral commands, so the game starts without waiting for anybody.
16
+ #
17
+ # When a command is missing the simulation stalls rather than guessing.
18
+ # That is the deal lockstep makes: everyone waits for the slowest peer, and
19
+ # nobody ever has to take back something that already happened.
20
+ class Lockstep
21
+ DEFAULT_DELAY = 2 # ~57ms at 35Hz
22
+
23
+ attr_reader :local_id, :player_ids, :delay, :next_tic, :sample_tic
24
+
25
+ def initialize(local_id:, player_ids:, delay: DEFAULT_DELAY)
26
+ raise ArgumentError, 'delay must be >= 1' if delay < 1
27
+ raise ArgumentError, 'local_id must be one of player_ids' unless player_ids.include?(local_id)
28
+
29
+ @local_id = local_id
30
+ @player_ids = player_ids.dup.freeze
31
+ @delay = delay
32
+ @buffer = {} # tic => { player_id => Ticcmd }
33
+ @peer_acks = {} # player_id => lowest tic they still need
34
+ @local_sent = {} # tic => Ticcmd, kept for retransmission padding
35
+ @next_tic = 1 # next tic to simulate
36
+ @sample_tic = delay + 1 # tic the next local sample is for
37
+
38
+ # Prime the opening tics so play starts immediately instead of waiting
39
+ # a delay's worth of round trips for commands nobody has sent yet.
40
+ (1..delay).each do |tic|
41
+ @player_ids.each { |id| store(tic, id, Game::Ticcmd.none) }
42
+ end
43
+ end
44
+
45
+ # Schedule this frame's input. Returns the tic it was scheduled for, which
46
+ # is what the sender must label the outgoing packet with.
47
+ def submit_local(cmd)
48
+ tic = @sample_tic
49
+ @sample_tic += 1
50
+ store(tic, @local_id, cmd)
51
+ @local_sent[tic] = cmd
52
+ # Prune here too, not only when tics run: a stalled peer keeps sampling
53
+ # input while unable to advance, which is exactly when the history
54
+ # would otherwise grow without bound.
55
+ prune
56
+ tic
57
+ end
58
+
59
+ # A command from a peer. Commands for tics already simulated are dropped;
60
+ # duplicates are ignored rather than overwriting, since the transport
61
+ # deliberately resends recent commands and a late copy must not change a
62
+ # tic that is already decided.
63
+ def receive(tic, player_id, cmd)
64
+ return false if tic < @next_tic
65
+ return false unless @player_ids.include?(player_id)
66
+ return false if @buffer.dig(tic, player_id)
67
+
68
+ store(tic, player_id, cmd)
69
+ true
70
+ end
71
+
72
+ def ready?
73
+ missing_for(@next_tic).empty?
74
+ end
75
+
76
+ # Players holding up the current tic. Worth surfacing: a stall with no
77
+ # explanation looks like a freeze.
78
+ def waiting_on
79
+ missing_for(@next_tic)
80
+ end
81
+
82
+ def stalled?
83
+ !ready?
84
+ end
85
+
86
+ # Commands for the current tic, advancing to the next. Returns nil if the
87
+ # tic is not ready.
88
+ def take_cmds
89
+ return nil unless ready?
90
+
91
+ cmds = @buffer.delete(@next_tic)
92
+ @next_tic += 1
93
+ prune
94
+ cmds
95
+ end
96
+
97
+ # Run every tic whose commands have arrived. Returns how many ran, so a
98
+ # caller can tell a stall from an idle moment.
99
+ def run_ready(world, limit: 10)
100
+ ran = 0
101
+ while ran < limit && (cmds = take_cmds)
102
+ world.run_tic(cmds)
103
+ ran += 1
104
+ end
105
+ ran
106
+ end
107
+
108
+ # Local commands to put in a packet for a peer that is waiting on `ack`.
109
+ #
110
+ # Resending from the peer's own position, rather than just the newest
111
+ # few, is what makes loss survivable: a fixed window drops a command for
112
+ # good once it slides past, and lockstep never skips a tic, so both sides
113
+ # would deadlock on it. `count` still bounds the packet.
114
+ def local_since(ack, count)
115
+ tics = @local_sent.keys.sort
116
+ pending = tics.select { |tic| tic >= ack }
117
+ pending = tics.last(count) if pending.empty?
118
+ pending.first(count).map { |tic| [tic, @local_sent[tic]] }
119
+ end
120
+
121
+ # The lowest tic still missing locally: what peers should resend from.
122
+ def ack_tic
123
+ @next_tic
124
+ end
125
+
126
+ # Where a peer says it has got to. Recorded so we never discard a command
127
+ # that somebody still needs.
128
+ def note_ack(player_id, tic)
129
+ return unless @player_ids.include?(player_id) && player_id != @local_id
130
+
131
+ current = @peer_acks[player_id]
132
+ @peer_acks[player_id] = tic if current.nil? || tic > current
133
+ end
134
+
135
+ private
136
+
137
+ def store(tic, player_id, cmd)
138
+ (@buffer[tic] ||= {})[player_id] = cmd
139
+ end
140
+
141
+ def missing_for(tic)
142
+ have = @buffer[tic] || {}
143
+ @player_ids.reject { |id| have.key?(id) }
144
+ end
145
+
146
+ # Sent commands are kept until every peer has acknowledged them, not for a
147
+ # fixed number of tics. A peer stalled on a tic we already ran is freed
148
+ # only by us resending it, and if we have thrown it away it exists
149
+ # nowhere: they wait forever. That is what a paused peer looks like --
150
+ # it stops acknowledging, and a fixed window of 64 tics meant any pause
151
+ # longer than two seconds permanently broke the session.
152
+ #
153
+ # LOCAL_HISTORY is now only a backstop against a peer that is never
154
+ # coming back, at roughly a minute of tics rather than two seconds.
155
+ LOCAL_HISTORY = 2048
156
+
157
+ def prune
158
+ @buffer.delete_if { |tic, _| tic < @next_tic }
159
+
160
+ # Anything at or above the least-caught-up peer must be kept.
161
+ floor = @peer_acks.values.min
162
+ @local_sent.delete_if { |tic, _| floor && tic < floor }
163
+ return if @local_sent.size <= LOCAL_HISTORY
164
+
165
+ keep = @local_sent.keys.sort.last(LOCAL_HISTORY)
166
+ @local_sent.select! { |tic, _| keep.include?(tic) }
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,350 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doom
4
+ module Net
5
+ # Wire format for both multiplayer stacks: the peer-to-peer lockstep mesh
6
+ # (HELLO/SETUP/TICCMD/HASH/QUIT/PEERS) and the authoritative star server
7
+ # (WELCOME/SNAPSHOT/INPUT/FRAME).
8
+ #
9
+ # Kept separate from the socket so it can be tested without one, and so
10
+ # nothing that parses bytes off the network has any business touching a
11
+ # file descriptor.
12
+ #
13
+ # Every decode path is defensive: these bytes come from the network, and a
14
+ # truncated or malformed packet must produce nil, never an exception.
15
+ # Anything unrecognised is simply dropped.
16
+ module Protocol
17
+ VERSION = 1
18
+
19
+ HELLO = 1 # I would like to join
20
+ SETUP = 2 # Here is the game you are joining, and who you are
21
+ TICCMD = 3 # Input for one or more tics
22
+ HASH = 4 # State fingerprint for a checkpoint tic
23
+ QUIT = 5 # I am leaving
24
+ PEERS = 6 # Everyone else's address, so the mesh can form
25
+
26
+ # Star-topology server packets (Net::GameServer). Separate from the peer
27
+ # lockstep ones above: the server is authoritative and never waits, so
28
+ # its packets carry finalized tics, not proposals.
29
+ WELCOME = 7 # server -> joiner: your id and the game, snapshot to follow
30
+ SNAPSHOT = 8 # server -> joiner: one chunk of the world snapshot
31
+ INPUT = 9 # client -> server: my ticcmd for a future tic
32
+ FRAME = 10 # server -> clients: the finalized tics everyone must run
33
+
34
+ MAX_CMDS_PER_PACKET = 32
35
+ # Payload per snapshot chunk, kept well under a typical path MTU so a
36
+ # chunk is never itself IP-fragmented.
37
+ SNAPSHOT_CHUNK_BYTES = 1024
38
+ # Ceiling for a FRAME packet. A frame carries every player's command, and
39
+ # FRAME_REDUNDANCY frames are repeated per packet against loss, so at 30
40
+ # players the packet approaches the ~1500-byte mobile MTU and past it IP
41
+ # fragments -- and the receiver's fixed recv buffer would silently truncate
42
+ # it. Kept under the IPv6 minimum MTU (1280) minus headers so a FRAME never
43
+ # fragments; encode_frame_capped drops the oldest (most-redundant) frames
44
+ # to fit, always keeping at least the newest.
45
+ MAX_FRAME_BYTES = 1200
46
+
47
+ module_function
48
+
49
+ # HELLO carries nothing but "let me in": the server (or host) assigns the
50
+ # id and tells the joiner everything else.
51
+ def encode_hello
52
+ [VERSION, HELLO].pack('CC')
53
+ end
54
+
55
+ def encode_setup(player_id:, num_players:, seed:, map:, mode:, skill:)
56
+ [VERSION, SETUP, player_id, num_players, seed, mode_code(mode), skill].pack('CCCCCCC') +
57
+ encode_string(map)
58
+ end
59
+
60
+ # `pairs` is [[tic, Ticcmd], ...]. Packets carry several commands so a
61
+ # lost one is covered by the next packet: a ticcmd is 7 bytes, far
62
+ # cheaper to repeat than to request again over a round trip.
63
+ #
64
+ # `ack` is the lowest tic the sender is still waiting for. Peers resend
65
+ # from there, so a command cannot be lost for good by falling out of a
66
+ # fixed-size window -- with real packet loss that eventually deadlocks
67
+ # both sides, since lockstep never skips a tic.
68
+ def encode_ticcmds(player_id, pairs, ack: 0)
69
+ pairs = pairs.last(MAX_CMDS_PER_PACKET)
70
+ body = pairs.map { |tic, cmd| [tic].pack('L<') + cmd.pack }.join
71
+ [VERSION, TICCMD, player_id, pairs.size].pack('CCCC') + [ack].pack('L<') + body
72
+ end
73
+
74
+ def encode_hash(tic, player_id, sections)
75
+ values = Game::StateHash::SECTIONS.map { |name| sections[name].to_i }
76
+ [VERSION, HASH, player_id].pack('CCC') + [tic].pack('L<') + values.pack('L<*')
77
+ end
78
+
79
+ def encode_quit(player_id)
80
+ [VERSION, QUIT, player_id].pack('CCC')
81
+ end
82
+
83
+ # `entries` is [[player_id, host, port], ...]. The host sends this so
84
+ # clients can talk to each other directly instead of relaying everything
85
+ # through it, which would add a hop of latency to every command.
86
+ def encode_peers(entries)
87
+ body = entries.map do |player_id, host, port|
88
+ [player_id].pack('C') + encode_string(host) + [port].pack('S<')
89
+ end.join
90
+ [VERSION, PEERS, entries.size].pack('CCC') + body
91
+ end
92
+
93
+ # server -> joiner. Says who they are and what game this is; the world
94
+ # itself follows as SNAPSHOT chunks for the same snapshot_tic. No RNG seed
95
+ # is sent: the snapshot already carries the exact RNG state to resume from.
96
+ def encode_welcome(player_id:, num_players:, mode:, skill:, snapshot_tic:, map:)
97
+ [VERSION, WELCOME, player_id, num_players, mode_code(mode), skill].pack('CCCCCC') +
98
+ [snapshot_tic].pack('L<') + encode_string(map)
99
+ end
100
+
101
+ def encode_snapshot_chunk(snapshot_tic, index, total, chunk_bytes)
102
+ [VERSION, SNAPSHOT].pack('CC') + [snapshot_tic, index, total].pack('L<S<S<') + chunk_bytes.b
103
+ end
104
+
105
+ # Split a full snapshot into datagram-sized chunks for one tic.
106
+ def snapshot_chunks(snapshot_tic, bytes)
107
+ bytes = bytes.b
108
+ total = [(bytes.bytesize + SNAPSHOT_CHUNK_BYTES - 1) / SNAPSHOT_CHUNK_BYTES, 1].max
109
+ (0...total).map do |i|
110
+ encode_snapshot_chunk(snapshot_tic, i, total,
111
+ bytes.byteslice(i * SNAPSHOT_CHUNK_BYTES, SNAPSHOT_CHUNK_BYTES) || ''.b)
112
+ end
113
+ end
114
+
115
+ # client -> server. `pairs` is [[tic, Ticcmd], ...]; recent inputs are
116
+ # repeated so a lost packet is covered by the next, as with TICCMD.
117
+ def encode_input(player_id, pairs)
118
+ pairs = pairs.last(MAX_CMDS_PER_PACKET)
119
+ body = pairs.map { |tic, cmd| [tic].pack('L<') + cmd.pack }.join
120
+ [VERSION, INPUT, player_id, pairs.size].pack('CCCC') + body
121
+ end
122
+
123
+ # server -> clients. A batch of finalized tics, oldest first. Each tic
124
+ # carries the membership changes to apply (joins and leaves are tic
125
+ # events, because a deathmatch spawn draws the shared RNG and must happen
126
+ # on the same tic everywhere) and then every player's command.
127
+ def encode_frame(frames)
128
+ frames = frames.last(MAX_CMDS_PER_PACKET)
129
+ body = frames.map do |f|
130
+ [f[:tic]].pack('L<') +
131
+ [f[:joins].size].pack('C') + f[:joins].pack('C*') +
132
+ [f[:leaves].size].pack('C') + f[:leaves].pack('C*') +
133
+ [f[:cmds].size].pack('C') +
134
+ f[:cmds].map { |id, cmd| [id].pack('C') + cmd.pack }.join
135
+ end.join
136
+ [VERSION, FRAME, frames.size].pack('CCC') + body
137
+ end
138
+
139
+ # Encode the newest frames that fit under `max_bytes`, dropping the oldest
140
+ # (whose only job is redundancy) until the packet fits. Always keeps at
141
+ # least the newest frame, so a client can still advance even when a single
142
+ # frame at a huge player count is itself over budget.
143
+ def encode_frame_capped(frames, max_bytes: MAX_FRAME_BYTES)
144
+ selected = frames
145
+ loop do
146
+ packet = encode_frame(selected)
147
+ return packet if packet.bytesize <= max_bytes || selected.size <= 1
148
+
149
+ selected = selected[1..] # drop the oldest, retry
150
+ end
151
+ end
152
+
153
+ def decode(bytes)
154
+ return nil if bytes.nil? || bytes.bytesize < 2
155
+
156
+ bytes = bytes.b
157
+ version, type = bytes.unpack('CC')
158
+ return nil unless version == VERSION
159
+
160
+ case type
161
+ when HELLO then decode_hello(bytes)
162
+ when SETUP then decode_setup(bytes)
163
+ when TICCMD then decode_ticcmds(bytes)
164
+ when HASH then decode_hash(bytes)
165
+ when QUIT then decode_quit(bytes)
166
+ when PEERS then decode_peers(bytes)
167
+ when WELCOME then decode_welcome(bytes)
168
+ when SNAPSHOT then decode_snapshot_chunk(bytes)
169
+ when INPUT then decode_input(bytes)
170
+ when FRAME then decode_frame(bytes)
171
+ end
172
+ end
173
+
174
+ def decode_welcome(bytes)
175
+ return nil if bytes.bytesize < 10
176
+
177
+ _, _, player_id, num_players, mode, skill = bytes.unpack('CCCCCC')
178
+ snapshot_tic = bytes[6, 4].unpack1('L<')
179
+ map, = decode_string(bytes, 10)
180
+ return nil if map.nil? || map.empty?
181
+
182
+ { type: WELCOME, player_id: player_id, num_players: num_players,
183
+ mode: mode_name(mode), skill: skill, snapshot_tic: snapshot_tic, map: map }
184
+ end
185
+
186
+ def decode_snapshot_chunk(bytes)
187
+ return nil if bytes.bytesize < 10
188
+
189
+ snapshot_tic, index, total = bytes[2, 8].unpack('L<S<S<')
190
+ { type: SNAPSHOT, snapshot_tic: snapshot_tic, index: index, total: total,
191
+ chunk: bytes.byteslice(10, bytes.bytesize - 10) }
192
+ end
193
+
194
+ def decode_input(bytes)
195
+ return nil if bytes.bytesize < 4
196
+
197
+ _, _, player_id, count = bytes.unpack('CCCC')
198
+ entry = 4 + Game::Ticcmd::PACKED_SIZE
199
+ return nil if bytes.bytesize < 4 + (count * entry)
200
+
201
+ cmds = Array.new(count) do |i|
202
+ at = 4 + (i * entry)
203
+ [bytes[at, 4].unpack1('L<'), Game::Ticcmd.unpack(bytes[at + 4, Game::Ticcmd::PACKED_SIZE])]
204
+ end
205
+ { type: INPUT, player_id: player_id, cmds: cmds }
206
+ end
207
+
208
+ def decode_frame(bytes)
209
+ return nil if bytes.bytesize < 3
210
+
211
+ count = bytes.unpack('CCC')[2]
212
+ offset = 3
213
+ frames = []
214
+ entry = 1 + Game::Ticcmd::PACKED_SIZE
215
+
216
+ count.times do
217
+ return nil if bytes.bytesize < offset + 5
218
+
219
+ tic = bytes[offset, 4].unpack1('L<')
220
+ offset += 4
221
+ njoins = bytes[offset].unpack1('C')
222
+ offset += 1
223
+ return nil if bytes.bytesize < offset + njoins
224
+
225
+ joins = bytes[offset, njoins].unpack('C*')
226
+ offset += njoins
227
+ return nil if bytes.bytesize < offset + 1
228
+
229
+ nleaves = bytes[offset].unpack1('C')
230
+ offset += 1
231
+ return nil if bytes.bytesize < offset + nleaves
232
+
233
+ leaves = bytes[offset, nleaves].unpack('C*')
234
+ offset += nleaves
235
+ return nil if bytes.bytesize < offset + 1
236
+
237
+ ncmds = bytes[offset].unpack1('C')
238
+ offset += 1
239
+ return nil if bytes.bytesize < offset + (ncmds * entry)
240
+
241
+ cmds = Array.new(ncmds) do |i|
242
+ at = offset + (i * entry)
243
+ [bytes[at].unpack1('C'), Game::Ticcmd.unpack(bytes[at + 1, Game::Ticcmd::PACKED_SIZE])]
244
+ end
245
+ offset += ncmds * entry
246
+ frames << { tic: tic, joins: joins, leaves: leaves, cmds: cmds }
247
+ end
248
+
249
+ { type: FRAME, frames: frames }
250
+ end
251
+
252
+ def decode_peers(bytes)
253
+ return nil if bytes.bytesize < 3
254
+
255
+ count = bytes.unpack('CCC')[2]
256
+ offset = 3
257
+ entries = []
258
+
259
+ count.times do
260
+ return nil if bytes.bytesize < offset + 1
261
+
262
+ player_id = bytes[offset].unpack1('C')
263
+ host, offset = decode_string(bytes, offset + 1)
264
+ return nil if host.nil?
265
+ return nil if bytes.bytesize < offset + 2
266
+
267
+ entries << [player_id, host, bytes[offset, 2].unpack1('S<')]
268
+ offset += 2
269
+ end
270
+
271
+ { type: PEERS, peers: entries }
272
+ end
273
+
274
+ def decode_hello(_bytes)
275
+ { type: HELLO }
276
+ end
277
+
278
+ def decode_setup(bytes)
279
+ return nil if bytes.bytesize < 7
280
+
281
+ _, _, player_id, num_players, seed, mode, skill = bytes.unpack('CCCCCCC')
282
+ map, = decode_string(bytes, 7)
283
+ return nil if map.nil? || map.empty?
284
+
285
+ { type: SETUP, player_id: player_id, num_players: num_players, seed: seed,
286
+ mode: mode_name(mode), skill: skill, map: map }
287
+ end
288
+
289
+ def decode_ticcmds(bytes)
290
+ return nil if bytes.bytesize < 8
291
+
292
+ _, _, player_id, count = bytes.unpack('CCCC')
293
+ ack = bytes[4, 4].unpack1('L<')
294
+ entry = 4 + Game::Ticcmd::PACKED_SIZE
295
+ return nil if bytes.bytesize < 8 + (count * entry)
296
+
297
+ cmds = Array.new(count) do |i|
298
+ at = 8 + (i * entry)
299
+ tic = bytes[at, 4].unpack1('L<')
300
+ [tic, Game::Ticcmd.unpack(bytes[at + 4, Game::Ticcmd::PACKED_SIZE])]
301
+ end
302
+
303
+ { type: TICCMD, player_id: player_id, ack: ack, cmds: cmds }
304
+ end
305
+
306
+ def decode_hash(bytes)
307
+ names = Game::StateHash::SECTIONS
308
+ return nil if bytes.bytesize < 7 + (names.size * 4)
309
+
310
+ _, _, player_id = bytes.unpack('CCC')
311
+ tic = bytes[3, 4].unpack1('L<')
312
+ values = bytes[7, names.size * 4].unpack('L<*')
313
+
314
+ { type: HASH, player_id: player_id, tic: tic,
315
+ sections: names.zip(values).to_h }
316
+ end
317
+
318
+ def decode_quit(bytes)
319
+ return nil if bytes.bytesize < 3
320
+
321
+ { type: QUIT, player_id: bytes.unpack('CCC')[2] }
322
+ end
323
+
324
+ # Length-prefixed so a name or map with odd bytes cannot run off the end.
325
+ def encode_string(str)
326
+ s = str.to_s.b[0, 255]
327
+ [s.bytesize].pack('C') + s
328
+ end
329
+
330
+ def decode_string(bytes, offset)
331
+ return [nil, offset] if bytes.bytesize < offset + 1
332
+
333
+ len = bytes[offset].unpack1('C')
334
+ return [nil, offset] if bytes.bytesize < offset + 1 + len
335
+
336
+ [bytes[offset + 1, len], offset + 1 + len]
337
+ end
338
+
339
+ MODES = { 0 => :coop, 1 => :deathmatch }.freeze
340
+
341
+ def mode_code(mode)
342
+ MODES.key(mode.to_sym) || 0
343
+ end
344
+
345
+ def mode_name(code)
346
+ MODES.fetch(code, :coop)
347
+ end
348
+ end
349
+ end
350
+ end