Module: Doom::Game::StateHash

Defined in:
lib/doom/game/state_hash.rb

Overview

A fingerprint of the simulation, for catching lockstep desync.

Peers exchange this every so often. If two peers disagree at the same tic their worlds have diverged, and we want to say so loudly at the tic it happened rather than let the games drift silently apart.

Deliberately NOT built on Object#hash: Ruby randomises string and array hashing per process, so two peers would disagree on identical state and every session would look like a desync. Zlib.crc32 over an explicitly packed byte string is stable across processes and machines.

Floats are packed as raw IEEE754 ('E', little-endian), not rounded. In lockstep a one-ULP difference is a real divergence -- it compounds -- so the check must see it.

Constant Summary collapse

SECTIONS =

Sections are hashed separately as well as folded together. When a desync fires, the section that differs says where to look.

%i[rng players monsters combat sectors].freeze

Class Method Summary collapse

Class Method Details

.crc(bytes) ⇒ Object



46
47
48
# File 'lib/doom/game/state_hash.rb', line 46

def crc(bytes)
  Zlib.crc32(bytes)
end

.fold(sections) ⇒ Object



42
43
44
# File 'lib/doom/game/state_hash.rb', line 42

def fold(sections)
  crc(SECTIONS.map { |name| sections[name] }.pack('L<*'))
end

.of(world) ⇒ Object



28
29
30
# File 'lib/doom/game/state_hash.rb', line 28

def of(world)
  fold(sections(world))
end

.pack_combat(combat) ⇒ Object



82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
# File 'lib/doom/game/state_hash.rb', line 82

def pack_combat(combat)
  return '' unless combat

  hp = combat.instance_variable_get(:@monster_hp) || {}
  parts = [
    hp.sort.flatten.pack('q<*'),
    combat.dead_things.keys.sort.pack('q<*')
  ]
  # z and dz are as much sim state as x/y -- a projectile diverging only in
  # height still diverges. spawn_tic pins its identity, and the damage
  # multiplier it carries changes the damage it will deal on impact.
  parts << combat.projectiles.flat_map do |pr|
    [pr.x, pr.y, pr.z, pr.dx, pr.dy, pr.dz || 0.0, pr.damage_multiplier || 1.0]
  end.pack('E*')
  parts << combat.projectiles.map(&:spawn_tic).pack('q<*')
  parts.join
end

.pack_monsters(monster_ai) ⇒ Object



65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
# File 'lib/doom/game/state_hash.rb', line 65

def pack_monsters(monster_ai)
  return '' unless monster_ai

  things = monster_ai.instance_variable_get(:@map).things
  monster_ai.monsters.flat_map do |m|
    thing = things[m.thing_idx]
    [
      m.thing_idx, m.movedir, m.movecount, (m.active ? 1 : 0),
      (m.attacking ? 1 : 0), m.target&.id || -1,
      # The map thing's integer position, which hitscan traces against,
      # lags mon.x/mon.y and is distinct sim state -- a desync in it makes
      # bullets miss where a monster appears to be.
      thing.x, thing.y
    ].pack('q<*') + [m.x, m.y].pack('E*')
  end.join
end

.pack_players(players) ⇒ Object



50
51
52
53
54
55
56
57
58
59
60
61
62
63
# File 'lib/doom/game/state_hash.rb', line 50

def pack_players(players)
  players.flat_map do |p|
    s = p.state
    [
      p.id, s.health, s.armor, s.weapon, (s.dead ? 1 : 0),
      s.ammo_bullets, s.ammo_shells, s.ammo_rockets, s.ammo_cells,
      s.keys.count { |_, v| v },
      # Frags are simulation state: peers that disagree on the score
      # disagree on who won, and that must show up as a desync.
      p.frags
    ].pack('q<*') +
      [p.x, p.y, p.z, p.angle, p.momx, p.momy].pack('E*')
  end.join
end

.pack_sectors(world) ⇒ Object

Sector heights as well as lights. Doors and lifts move floor and ceiling heights, and until now the fingerprint watched only the light level -- so a door desyncing between peers went unnoticed. Active door and lift animations are folded in too, so a divergence is caught while the sector is still moving rather than only once it settles.



105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
# File 'lib/doom/game/state_hash.rb', line 105

def pack_sectors(world)
  sectors = world.map.sectors
  heights = sectors.flat_map { |s| [s.light_level, s.floor_height, s.ceiling_height] }

  actions = world.sector_actions
  doors = actions.instance_variable_get(:@active_doors) || {}
  lifts = actions.instance_variable_get(:@active_lifts) || {}
  sorted_lifts = lifts.sort_by { |idx, _| idx }
  moving = doors.sort_by { |idx, _| idx }.flat_map { |idx, d| [idx, d[:state], d[:wait_tics]] } +
           sorted_lifts.flat_map { |idx, l| [idx, l[:wait_tics]] }
  # A lift's :state is a symbol (going down / waiting / coming up), not an
  # integer like a door's, so it is folded in as bytes rather than packed
  # with the integer run -- otherwise a lift changing phase between peers
  # would go unnoticed the way a moving door once did.
  lift_states = sorted_lifts.map { |_, l| l[:state].to_s }.join("\0")

  heights.pack('q<*') + moving.pack('q<*') + lift_states
end

.sections(world) ⇒ Object



32
33
34
35
36
37
38
39
40
# File 'lib/doom/game/state_hash.rb', line 32

def sections(world)
  {
    rng: crc([world.leveltime, world.random.index].pack('q<q<')),
    players: crc(pack_players(world.players)),
    monsters: crc(pack_monsters(world.monster_ai)),
    combat: crc(pack_combat(world.combat)),
    sectors: crc(pack_sectors(world))
  }
end