doom 0.6.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 +138 -25
  5. data/lib/doom/benchmark.rb +282 -0
  6. data/lib/doom/game/animations.rb +9 -2
  7. data/lib/doom/game/combat.rb +377 -105
  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 +231 -0
  11. data/lib/doom/game/item_pickup.rb +108 -60
  12. data/lib/doom/game/menu.rb +347 -0
  13. data/lib/doom/game/monster_ai.rb +304 -91
  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 +47 -64
  17. data/lib/doom/game/random.rb +82 -0
  18. data/lib/doom/game/sector_actions.rb +466 -29
  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 +176 -0
  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 +820 -640
  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 +80 -0
  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 +684 -330
  43. data/lib/doom/render/renderer_factory.rb +50 -0
  44. data/lib/doom/render/screen_melt.rb +71 -0
  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 +15 -17
  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 +59 -10
  57. data/lib/doom/wad/sound.rb +93 -0
  58. data/lib/doom/wad/sprite.rb +60 -34
  59. data/lib/doom/wad/texture.rb +5 -5
  60. data/lib/doom/wad_downloader.rb +38 -56
  61. data/lib/doom.rb +235 -12
  62. metadata +100 -7
@@ -0,0 +1,584 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doom
4
+ module Game
5
+ # A complete, restorable copy of a World, as bytes.
6
+ #
7
+ # This is deliberately not the same thing as StateHash. The hash is a
8
+ # fingerprint: enough to notice two worlds have diverged. A snapshot has to
9
+ # do the harder thing -- carry enough that a fresh world restored from it
10
+ # goes on to simulate byte-for-byte identically. So it captures state the
11
+ # hash skips because it does not change the current tic's picture: monster
12
+ # attack timers, sector-effect countdowns, the full projectile list, door
13
+ # and lift animations in progress.
14
+ #
15
+ # Why it exists: a player joining a running match cannot replay the round
16
+ # from the start -- at 30 players that is nearly a minute of catch-up and
17
+ # it grows with the match. Handed a snapshot, they resume in one step.
18
+ #
19
+ # This module reaches directly into subsystem internals. That coupling is
20
+ # intentional and concentrated here, in one place, so the wire format lives
21
+ # in one file rather than smeared across eight. The round-trip spec is what
22
+ # keeps it honest: it restores a snapshot and runs both worlds forward,
23
+ # failing the moment a field was missed.
24
+ module Snapshot
25
+ VERSION = 1
26
+
27
+ # A cursor-based binary writer, so record offsets are never hand-managed.
28
+ class Writer
29
+ def initialize
30
+ @buf = +''.b
31
+ end
32
+
33
+ def u8(v) = append([v & 0xff].pack('C'))
34
+ def u16(v) = append([v & 0xffff].pack('S<'))
35
+ def u32(v) = append([v & 0xffffffff].pack('L<'))
36
+ def i64(v) = append([v].pack('q<'))
37
+ def f64(v) = append([v].pack('E'))
38
+ def bool(v) = u8(v ? 1 : 0)
39
+
40
+ def str(s)
41
+ bytes = s.to_s.b
42
+ u16(bytes.bytesize)
43
+ append(bytes)
44
+ end
45
+
46
+ # length-prefixed homogeneous array
47
+ def list(items, &)
48
+ u32(items.size)
49
+ items.each(&)
50
+ end
51
+
52
+ def to_s = @buf.dup
53
+
54
+ private
55
+
56
+ def append(bytes)
57
+ @buf << bytes
58
+ self
59
+ end
60
+ end
61
+
62
+ class Reader
63
+ def initialize(bytes)
64
+ @buf = bytes.b
65
+ @pos = 0
66
+ end
67
+
68
+ def u8 = take(1).unpack1('C')
69
+ def u16 = take(2).unpack1('S<')
70
+ def u32 = take(4).unpack1('L<')
71
+ def i64 = take(8).unpack1('q<')
72
+ def f64 = take(8).unpack1('E')
73
+ def bool = u8 == 1
74
+
75
+ def str
76
+ take(u16)
77
+ end
78
+
79
+ def list(&)
80
+ Array.new(u32, &)
81
+ end
82
+
83
+ private
84
+
85
+ def take(n)
86
+ raise 'snapshot truncated' if @pos + n > @buf.bytesize
87
+
88
+ slice = @buf.byteslice(@pos, n)
89
+ @pos += n
90
+ slice
91
+ end
92
+ end
93
+
94
+ NO_ID = 0xff # sentinel for "no player" (target/owner)
95
+ PROJ_TARGETS = { monsters: 0, player: 1 }.freeze
96
+ PROJ_TARGET_NAMES = PROJ_TARGETS.invert.freeze
97
+
98
+ module_function
99
+
100
+ # Serialize a whole world to bytes.
101
+ def dump(world)
102
+ w = Writer.new
103
+ w.u8(VERSION)
104
+ dump_header(w, world)
105
+ dump_map_state(w, world.map)
106
+ dump_players(w, world)
107
+ dump_combat(w, world.combat)
108
+ dump_monsters(w, world.monster_ai)
109
+ dump_item_pickup(w, world.item_pickup)
110
+ dump_sector_effects(w, world.sector_effects)
111
+ dump_sector_actions(w, world.sector_actions, world.map)
112
+ w.to_s
113
+ end
114
+
115
+ # Rebuild a world from bytes. The caller supplies the environment the
116
+ # joiner already has -- a freshly loaded map for the same level, the
117
+ # sprite set, and a sound engine (or nil headless). Everything that
118
+ # varies during play comes out of the bytes.
119
+ def load(bytes, map:, sprites:, sound: nil)
120
+ r = Reader.new(bytes)
121
+ version = r.u8
122
+ raise "unknown snapshot version #{version}" unless version == VERSION
123
+
124
+ header = load_header(r)
125
+ world = World.new(map, sprites: sprites, sound: sound,
126
+ random: Random.new(0), mode: header[:mode],
127
+ frag_limit: header[:frag_limit])
128
+ world.instance_variable_set(:@leveltime, header[:leveltime])
129
+ world.instance_variable_set(:@damage_multiplier, header[:damage_multiplier])
130
+ # World.rebuild_actor_subsystems mirrors the skill's damage factor onto
131
+ # the monster AI; a fresh world built here for a load skips that path, so
132
+ # set it explicitly or restored monsters would deal 1.0x damage.
133
+ world.monster_ai.damage_multiplier = header[:damage_multiplier]
134
+ world.random.index = header[:random_index]
135
+
136
+ load_map_state(r, map)
137
+ load_players(r, world, map)
138
+ load_combat(r, world.combat, header[:combat_tic])
139
+ relink_projectile_owners(world)
140
+ load_monsters(r, world, header[:monster_tic], header[:aggression])
141
+ load_item_pickup(r, world.item_pickup)
142
+ load_sector_effects(r, world.sector_effects)
143
+ load_sector_actions(r, world.sector_actions, map)
144
+ world
145
+ end
146
+
147
+ # --- header -------------------------------------------------------------
148
+
149
+ def dump_header(w, world)
150
+ w.u32(world.leveltime)
151
+ w.u8(World::MODES.index(world.mode))
152
+ w.u32(world.frag_limit || 0)
153
+ w.f64(world.damage_multiplier)
154
+ w.u8(world.random.index)
155
+ w.u32(world.combat.instance_variable_get(:@tic))
156
+ w.u32(world.monster_ai.instance_variable_get(:@tic_counter))
157
+ w.bool(world.monster_ai.aggression)
158
+ end
159
+
160
+ def load_header(r)
161
+ {
162
+ leveltime: r.u32,
163
+ mode: World::MODES[r.u8],
164
+ frag_limit: (fl = r.u32).zero? ? nil : fl,
165
+ damage_multiplier: r.f64,
166
+ random_index: r.u8,
167
+ combat_tic: r.u32,
168
+ monster_tic: r.u32,
169
+ aggression: r.bool
170
+ }
171
+ end
172
+
173
+ # --- map: the parts doors, lifts and light effects mutate ---------------
174
+
175
+ def dump_map_state(w, map)
176
+ w.list(map.sectors) do |sec|
177
+ w.i64(sec.light_level)
178
+ w.i64(sec.floor_height)
179
+ w.i64(sec.ceiling_height)
180
+ end
181
+ end
182
+
183
+ def load_map_state(r, map)
184
+ count = r.u32
185
+ count.times do |i|
186
+ light = r.i64
187
+ floor = r.i64
188
+ ceil = r.i64
189
+ sec = map.sectors[i]
190
+ next unless sec
191
+
192
+ sec.light_level = light
193
+ sec.floor_height = floor
194
+ sec.ceiling_height = ceil
195
+ end
196
+ end
197
+
198
+ # --- players ------------------------------------------------------------
199
+
200
+ def dump_players(w, world)
201
+ w.list(world.players) do |p|
202
+ w.u8(p.id)
203
+ w.i64(p.frags)
204
+ w.bool(p.use_held)
205
+ w.f64(p.x)
206
+ w.f64(p.y)
207
+ w.f64(p.z)
208
+ w.f64(p.angle)
209
+ w.f64(p.momx)
210
+ w.f64(p.momy)
211
+
212
+ phys = world.physics_for(p)
213
+ w.f64(phys.floor_z || 0.0)
214
+ w.bool(!phys.floor_z.nil?)
215
+ w.f64(phys.momz)
216
+
217
+ dump_player_state(w, p.state)
218
+ end
219
+ end
220
+
221
+ def load_players(r, world, map)
222
+ count = r.u32
223
+ count.times do
224
+ id = r.u8
225
+ player = world.add_player(map.player_start_for(id) || map.player_start, id: id)
226
+ player.frags = r.i64
227
+ player.use_held = r.bool
228
+ player.x = r.f64
229
+ player.y = r.f64
230
+ player.z = r.f64
231
+ player.angle = r.f64
232
+ player.momx = r.f64
233
+ player.momy = r.f64
234
+ player.snapshot! # collapse interpolation onto the restored pose
235
+
236
+ phys = world.physics_for(player)
237
+ floor_z = r.f64
238
+ has_floor = r.bool
239
+ phys.instance_variable_set(:@floor_z, has_floor ? floor_z : nil)
240
+ phys.instance_variable_set(:@momz, r.f64)
241
+
242
+ load_player_state(r, player.state)
243
+ end
244
+ end
245
+
246
+ PLAYER_STATE_INTS = %i[
247
+ health armor max_health max_armor
248
+ ammo_bullets ammo_shells ammo_rockets ammo_cells
249
+ max_bullets max_shells max_rockets max_cells
250
+ weapon attack_frame attack_tics death_tic damage_count
251
+ ].freeze
252
+
253
+ PLAYER_STATE_BOOLS = %i[attacking dead god_mode infinite_ammo is_moving].freeze
254
+
255
+ PLAYER_STATE_FLOATS = %i[
256
+ bob_angle bob_amount viewheight deltaviewheight view_bob_offset
257
+ momx momy thrust_x thrust_y view_bob_angle
258
+ ].freeze
259
+
260
+ def dump_player_state(w, s)
261
+ PLAYER_STATE_INTS.each { |f| w.i64(s.instance_variable_get(:"@#{f}")) }
262
+ PLAYER_STATE_BOOLS.each { |f| w.bool(s.instance_variable_get(:"@#{f}")) }
263
+ PLAYER_STATE_FLOATS.each { |f| w.f64(s.instance_variable_get(:"@#{f}")) }
264
+
265
+ weapons = s.has_weapons
266
+ w.u16(weapons.each_index.sum { |i| weapons[i] ? (1 << i) : 0 })
267
+
268
+ keys = s.keys
269
+ w.list(keys.keys) do |k|
270
+ w.str(k.to_s)
271
+ w.bool(keys[k])
272
+ end
273
+ end
274
+
275
+ def load_player_state(r, s)
276
+ PLAYER_STATE_INTS.each { |f| s.instance_variable_set(:"@#{f}", r.i64) }
277
+ PLAYER_STATE_BOOLS.each { |f| s.instance_variable_set(:"@#{f}", r.bool) }
278
+ PLAYER_STATE_FLOATS.each { |f| s.instance_variable_set(:"@#{f}", r.f64) }
279
+
280
+ mask = r.u16
281
+ weapons = s.has_weapons
282
+ weapons.each_index { |i| weapons[i] = mask[i] == 1 }
283
+
284
+ keys = {}
285
+ r.list do
286
+ name = r.str
287
+ keys[name.to_sym] = r.bool
288
+ end
289
+ s.keys.replace(keys)
290
+ end
291
+
292
+ # --- combat -------------------------------------------------------------
293
+
294
+ def dump_combat(w, combat)
295
+ w.list(combat.instance_variable_get(:@monster_hp).to_a) do |idx, hp|
296
+ w.u32(idx)
297
+ w.i64(hp)
298
+ end
299
+ w.list(combat.dead_things.to_a) do |idx, info|
300
+ w.u32(idx)
301
+ w.u32(info[:tic])
302
+ w.str(info[:prefix].to_s)
303
+ end
304
+ w.list(combat.instance_variable_get(:@pain_until).to_a) do |idx, tic|
305
+ w.u32(idx)
306
+ w.u32(tic)
307
+ end
308
+ w.list(combat.projectiles) { |p| dump_projectile(w, p) }
309
+ end
310
+
311
+ def load_combat(r, combat, tic)
312
+ combat.instance_variable_set(:@tic, tic)
313
+
314
+ hp = {}
315
+ r.list do
316
+ idx = r.u32
317
+ hp[idx] = r.i64
318
+ end
319
+ combat.instance_variable_set(:@monster_hp, hp)
320
+
321
+ dead = {}
322
+ r.list do
323
+ idx = r.u32
324
+ dead[idx] = { tic: r.u32, prefix: r.str }
325
+ end
326
+ combat.instance_variable_set(:@dead_things, dead)
327
+
328
+ pain = {}
329
+ r.list do
330
+ idx = r.u32
331
+ pain[idx] = r.u32
332
+ end
333
+ combat.instance_variable_set(:@pain_until, pain)
334
+
335
+ projectiles = r.list { load_projectile(r) }
336
+ combat.instance_variable_set(:@projectiles, projectiles)
337
+ end
338
+
339
+ def dump_projectile(w, p)
340
+ w.f64(p.x)
341
+ w.f64(p.y)
342
+ w.f64(p.z)
343
+ w.f64(p.dx)
344
+ w.f64(p.dy)
345
+ w.f64(p.dz || 0.0)
346
+ w.str(p.type.to_s)
347
+ w.u32(p.spawn_tic)
348
+ w.str(p.sprite_prefix.to_s)
349
+ w.u8(PROJ_TARGETS.fetch(p.target, 0))
350
+ w.u8(p.owner&.id || NO_ID)
351
+ w.f64(p.damage_multiplier || 1.0)
352
+ end
353
+
354
+ def load_projectile(r)
355
+ Combat::Projectile.new(
356
+ r.f64, r.f64, r.f64, r.f64, r.f64, r.f64,
357
+ r.str.to_sym, r.u32, r.str, PROJ_TARGET_NAMES.fetch(r.u8, :monsters),
358
+ r.u8, # owner id; re-linked to a Player by relink_projectile_owners
359
+ r.f64 # damage_multiplier
360
+ )
361
+ end
362
+
363
+ # Projectiles carry their owner as an id on the wire; swap it for the
364
+ # Player object once players exist, or nil for an owner who has left.
365
+ def relink_projectile_owners(world)
366
+ by_id = world.players.to_h { |p| [p.id, p] }
367
+ world.combat.projectiles.each do |proj|
368
+ proj.owner = proj.owner == NO_ID ? nil : by_id[proj.owner]
369
+ end
370
+ end
371
+
372
+ # --- monsters -----------------------------------------------------------
373
+
374
+ MONSTER_INT_FIELDS = %i[thing_idx movedir movecount chase_timer type
375
+ attack_cooldown reactiontime last_saw_target attack_frame_tic].freeze
376
+ MONSTER_BOOL_FIELDS = %i[active attacking fired].freeze
377
+
378
+ def dump_monsters(w, ai)
379
+ things = ai.instance_variable_get(:@map).things
380
+ w.list(ai.monsters) do |m|
381
+ MONSTER_INT_FIELDS.each { |f| w.i64(m[f]) }
382
+ MONSTER_BOOL_FIELDS.each { |f| w.bool(m[f]) }
383
+ w.f64(m.x)
384
+ w.f64(m.y)
385
+ w.u8(m.target&.id || NO_ID)
386
+
387
+ # The map thing carries an integer-truncated copy of the monster's
388
+ # position, synced only when the monster moves (monster_ai.rb writes
389
+ # thing.x = mon.x.to_i). Hitscan traces against THIS, not against
390
+ # mon.x/mon.y, so it must be restored explicitly: a stale thing
391
+ # position makes bullets hit where the monster no longer is. It also
392
+ # lags mon.x.to_i for a monster that has paused, so it is stored
393
+ # directly rather than recomputed. This was the field whose absence
394
+ # broke the round-trip test once a lift was in play.
395
+ thing = things[m.thing_idx]
396
+ w.i64(thing.x)
397
+ w.i64(thing.y)
398
+ end
399
+ end
400
+
401
+ def load_monsters(r, world, tic, aggression)
402
+ ai = world.monster_ai
403
+ ai.instance_variable_set(:@tic_counter, tic)
404
+ ai.aggression = aggression
405
+ by_id = world.players.to_h { |p| [p.id, p] }
406
+
407
+ saved = r.list do
408
+ fields = {}
409
+ MONSTER_INT_FIELDS.each { |f| fields[f] = r.i64 }
410
+ MONSTER_BOOL_FIELDS.each { |f| fields[f] = r.bool }
411
+ fields[:x] = r.f64
412
+ fields[:y] = r.f64
413
+ fields[:target_id] = r.u8
414
+ fields[:thing_x] = r.i64
415
+ fields[:thing_y] = r.i64
416
+ fields
417
+ end
418
+
419
+ # The monster list is rebuilt from the map in a fixed order by the
420
+ # constructor, so match saved records to live monsters by thing index
421
+ # rather than trusting position.
422
+ things = world.map.things
423
+ live = ai.monster_by_thing_idx
424
+ saved.each do |f|
425
+ mon = live[f[:thing_idx]]
426
+ next unless mon
427
+
428
+ MONSTER_INT_FIELDS.each { |k| mon[k] = f[k] }
429
+ MONSTER_BOOL_FIELDS.each { |k| mon[k] = f[k] }
430
+ mon.x = f[:x]
431
+ mon.y = f[:y]
432
+ mon.target = f[:target_id] == NO_ID ? nil : by_id[f[:target_id]]
433
+
434
+ thing = things[f[:thing_idx]]
435
+ thing.x = f[:thing_x]
436
+ thing.y = f[:thing_y]
437
+ end
438
+ end
439
+
440
+ # --- item pickup --------------------------------------------------------
441
+
442
+ def dump_item_pickup(w, ip)
443
+ w.u32(ip.ammo_multiplier)
444
+ w.list(ip.picked_up.keys) { |idx| w.u32(idx) }
445
+ end
446
+
447
+ def load_item_pickup(r, ip)
448
+ ip.ammo_multiplier = r.u32
449
+ picked = {}
450
+ r.list { picked[r.u32] = true }
451
+ ip.picked_up.replace(picked)
452
+ end
453
+
454
+ # --- sector effects: rebuilt in map order, full state restored ----------
455
+ #
456
+ # The effect list rebuilds in a fixed order from the map, so records line
457
+ # up by index. But NOT only the countdown gets restored: a rebuilt effect
458
+ # reads its max and min light from the sector's CURRENT level, and at a
459
+ # snapshot mid-fade that level has already moved, so reconstruction alone
460
+ # gives a wrong max/min and the light drifts a few tics later. Every
461
+ # effect field is carried instead.
462
+ EFFECT_FIELDS = %i[@maxlight @minlight @count @direction @darktime @brighttime].freeze
463
+
464
+ def dump_sector_effects(w, effects)
465
+ list = effects.instance_variable_get(:@effects)
466
+ w.list(list) do |e|
467
+ EFFECT_FIELDS.each { |iv| w.i64(e.instance_variable_defined?(iv) ? e.instance_variable_get(iv) : 0) }
468
+ end
469
+ scrolls = effects.instance_variable_get(:@scroll_sides)
470
+ w.list(scrolls) { |side| w.i64(side.x_offset) }
471
+ end
472
+
473
+ def load_sector_effects(r, effects)
474
+ list = effects.instance_variable_get(:@effects)
475
+ records = r.list { EFFECT_FIELDS.map { r.i64 } }
476
+ records.each_with_index do |values, i|
477
+ e = list[i]
478
+ next unless e
479
+
480
+ EFFECT_FIELDS.each_with_index do |iv, j|
481
+ e.instance_variable_set(iv, values[j]) if e.instance_variable_defined?(iv)
482
+ end
483
+ end
484
+
485
+ scrolls = effects.instance_variable_get(:@scroll_sides)
486
+ offsets = r.list { r.i64 }
487
+ offsets.each_with_index { |off, i| scrolls[i]&.x_offset = off }
488
+ end
489
+
490
+ # --- sector actions: doors, lifts and trigger-once bookkeeping ----------
491
+
492
+ def dump_sector_actions(w, actions, _map)
493
+ w.list(actions.instance_variable_get(:@active_doors).to_a) do |idx, d|
494
+ w.u32(idx)
495
+ w.i64(d[:state]) # DOOR_OPENING/OPEN/CLOSING are integers, not symbols
496
+ w.i64(d[:target_height])
497
+ w.i64(d[:original_height])
498
+ w.u32(d[:wait_tics])
499
+ w.bool(d[:stay_open])
500
+ end
501
+ w.list(actions.instance_variable_get(:@active_lifts).to_a) do |idx, l|
502
+ w.u32(idx)
503
+ w.str(l[:state].to_s)
504
+ w.i64(l[:target_low])
505
+ w.i64(l[:original_height])
506
+ w.u32(l[:wait_tics])
507
+ end
508
+ w.list(actions.instance_variable_get(:@crossed_linedefs).keys) { |idx| w.u32(idx) }
509
+ w.list(actions.instance_variable_get(:@secrets_found).keys) { |idx| w.u32(idx) }
510
+
511
+ # Which side of each nearby line each player was last on. Walk triggers
512
+ # fire on a side change, so without this a restored world detects a
513
+ # crossing that never happened (or misses one), which silently
514
+ # activates a different set of lines and diverges tics later. This was
515
+ # the field that broke the round-trip test.
516
+ #
517
+ # It is nested per player id: every player crosses walk lines on their
518
+ # own, so each keeps its own side memory. Players are written in id
519
+ # order and lines in index order so the bytes are reproducible across
520
+ # peers. A nil side means "not yet seen", which is the same as the key
521
+ # being absent, so those entries are dropped rather than encoded.
522
+ near = actions.instance_variable_get(:@near_linedefs) || {}
523
+ w.list(near.sort_by { |pid, _| pid }) do |pid, sides|
524
+ w.u8(pid)
525
+ live = (sides || {}).reject { |_, side| side.nil? }.sort_by { |idx, _| idx }
526
+ w.list(live) do |(idx, side)|
527
+ w.u32(idx)
528
+ w.i64(side)
529
+ end
530
+ end
531
+
532
+ w.bool(!actions.exit_triggered.nil?)
533
+ w.str(actions.exit_triggered.to_s)
534
+ end
535
+
536
+ def load_sector_actions(r, actions, map)
537
+ doors = {}
538
+ r.list do
539
+ idx = r.u32
540
+ doors[idx] = {
541
+ sector: map.sectors[idx], state: r.i64,
542
+ target_height: r.i64, original_height: r.i64,
543
+ wait_tics: r.u32, stay_open: r.bool
544
+ }
545
+ end
546
+ actions.instance_variable_set(:@active_doors, doors)
547
+
548
+ lifts = {}
549
+ r.list do
550
+ idx = r.u32
551
+ lifts[idx] = {
552
+ sector: map.sectors[idx], state: r.str.to_sym,
553
+ target_low: r.i64, original_height: r.i64, wait_tics: r.u32
554
+ }
555
+ end
556
+ actions.instance_variable_set(:@active_lifts, lifts)
557
+
558
+ crossed = {}
559
+ r.list { crossed[r.u32] = true }
560
+ actions.instance_variable_set(:@crossed_linedefs, crossed)
561
+
562
+ secrets = {}
563
+ r.list { secrets[r.u32] = true }
564
+ actions.instance_variable_set(:@secrets_found, secrets)
565
+
566
+ near = {}
567
+ r.list do
568
+ pid = r.u8
569
+ sides = {}
570
+ r.list do
571
+ idx = r.u32
572
+ sides[idx] = r.i64
573
+ end
574
+ near[pid] = sides
575
+ end
576
+ actions.instance_variable_set(:@near_linedefs, near)
577
+
578
+ has_exit = r.bool
579
+ exit_val = r.str
580
+ actions.instance_variable_set(:@exit_triggered, has_exit ? exit_val.to_sym : nil)
581
+ end
582
+ end
583
+ end
584
+ end