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.
- checksums.yaml +4 -4
- data/LICENSE +17 -0
- data/README.md +65 -4
- data/bin/doom +134 -24
- data/lib/doom/benchmark.rb +282 -0
- data/lib/doom/game/animations.rb +9 -2
- data/lib/doom/game/combat.rb +239 -153
- data/lib/doom/game/framebuffer_blitter.rb +38 -0
- data/lib/doom/game/geometry.rb +68 -0
- data/lib/doom/game/intermission.rb +22 -39
- data/lib/doom/game/item_pickup.rb +87 -75
- data/lib/doom/game/menu.rb +90 -85
- data/lib/doom/game/monster_ai.rb +150 -136
- data/lib/doom/game/player.rb +105 -0
- data/lib/doom/game/player_physics.rb +384 -0
- data/lib/doom/game/player_state.rb +38 -64
- data/lib/doom/game/random.rb +82 -0
- data/lib/doom/game/sector_actions.rb +169 -93
- data/lib/doom/game/sector_effects.rb +25 -18
- data/lib/doom/game/snapshot.rb +584 -0
- data/lib/doom/game/sound_engine.rb +33 -58
- data/lib/doom/game/state_hash.rb +125 -0
- data/lib/doom/game/ticcmd.rb +61 -0
- data/lib/doom/game/world.rb +420 -0
- data/lib/doom/map/data.rb +95 -0
- data/lib/doom/net/client.rb +204 -0
- data/lib/doom/net/desync_monitor.rb +101 -0
- data/lib/doom/net/game_server.rb +232 -0
- data/lib/doom/net/lockstep.rb +170 -0
- data/lib/doom/net/protocol.rb +350 -0
- data/lib/doom/net/session.rb +282 -0
- data/lib/doom/net/transport.rb +110 -0
- data/lib/doom/platform/gosu_window.rb +604 -832
- data/lib/doom/platform/sdl.rb +74 -0
- data/lib/doom/platform/window_logic.rb +61 -0
- data/lib/doom/render/font.rb +5 -3
- data/lib/doom/render/hardware_renderer.rb +547 -0
- data/lib/doom/render/ray_tracing/bvh.rb +103 -0
- data/lib/doom/render/ray_tracing/material_state.rb +66 -0
- data/lib/doom/render/ray_tracing/texture_atlas.rb +78 -0
- data/lib/doom/render/ray_tracing_renderer.rb +940 -0
- data/lib/doom/render/renderer.rb +466 -440
- data/lib/doom/render/renderer_factory.rb +50 -0
- data/lib/doom/render/screen_melt.rb +7 -7
- data/lib/doom/render/spinel_native/kernel.rb +780 -0
- data/lib/doom/render/spinel_native_renderer.rb +162 -0
- data/lib/doom/render/status_bar.rb +14 -10
- data/lib/doom/render/weapon_renderer.rb +9 -10
- data/lib/doom/render/world_mesh.rb +270 -0
- data/lib/doom/render/zbuffer_renderer.rb +151 -0
- data/lib/doom/version.rb +1 -1
- data/lib/doom/wad/flat.rb +1 -1
- data/lib/doom/wad/hud_graphics.rb +7 -3
- data/lib/doom/wad/palette.rb +3 -3
- data/lib/doom/wad/patch.rb +1 -1
- data/lib/doom/wad/reader.rb +12 -15
- data/lib/doom/wad/sound.rb +14 -6
- data/lib/doom/wad/sprite.rb +36 -35
- data/lib/doom/wad/texture.rb +5 -5
- data/lib/doom/wad_downloader.rb +38 -56
- data/lib/doom.rb +197 -16
- 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
|