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
- .crc(bytes) ⇒ Object
- .fold(sections) ⇒ Object
- .of(world) ⇒ Object
- .pack_combat(combat) ⇒ Object
- .pack_monsters(monster_ai) ⇒ Object
- .pack_players(players) ⇒ Object
-
.pack_sectors(world) ⇒ Object
Sector heights as well as lights.
- .sections(world) ⇒ Object
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 |