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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c3cb562e8b88b905638cfa8792337bc1b7f818753fa90587bda474784513a6bd
4
- data.tar.gz: 1f421713b797c02dfc10e2f9bdd3ad0981a631f88bc94df161aa5a792b539094
3
+ metadata.gz: e8f1be6b4e908fb60c850e5ac7e4e9d5ade8fff60fd7c9744d89da3132625e5a
4
+ data.tar.gz: bdbc30dcb9141292e4c6ae8d974b604b0d8cdb440dac964bc637615fd9cf53b4
5
5
  SHA512:
6
- metadata.gz: b06fd18ebfa1c5755e27c11b49e9e568fc4e2acfc7bc2b9760e7b7dda500a438afe9a6ecf0e33aa4f17c2271a6c28205e6bbb793e3b9c618c4df24b8bf74679a
7
- data.tar.gz: ed5387b2c42963956cfe42c1cfe790a8f7f0e89a15c5a38934780b5ceaca34e3e64456756280d9c8f2d280763ef0fff89434dd8e826dc2e9e0184e25c4f94508
6
+ metadata.gz: e5a2273db5baa4bb26c5d9d08cc78b734ef102d7b8d354a95109e44e72bb8deaf901b743aa59a9a3ff4f1770059d13b2e7069f3f8be5d6ccaec942b7b69d3053
7
+ data.tar.gz: 6cb2b7e235928e9a72ab95a87a44c7be3c84dbc0f59bcd45d7c0e40f0f3ebee54a46198baea930191b2355cc7a713e326d8c412354ab6eee103adf7ebe9bf5c9
data/LICENSE ADDED
@@ -0,0 +1,17 @@
1
+ DOOM Ruby
2
+ Copyright (C) 2025 Chris Hasinski
3
+
4
+ This program is free software: you can redistribute it and/or modify it under
5
+ the terms of version 2 of the GNU General Public License as published by the
6
+ Free Software Foundation.
7
+
8
+ This program is distributed in the hope that it will be useful, but WITHOUT ANY
9
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
10
+ PARTICULAR PURPOSE. See the GNU General Public License for more details.
11
+
12
+ This matches the license of the original DOOM source release (GPL-2.0).
13
+
14
+ The full text of the GNU General Public License, version 2, is available at:
15
+ https://www.gnu.org/licenses/old-licenses/gpl-2.0.txt
16
+
17
+ SPDX-License-Identifier: GPL-2.0-only
data/README.md CHANGED
@@ -1,12 +1,17 @@
1
1
  # DOOM Ruby
2
2
 
3
- A faithful port of the DOOM (1993) engine to pure Ruby. Renders the original WAD files with near pixel-perfect BSP rendering, full HUD, item pickups, and hitscan/projectile combat.
3
+ A faithful port of the DOOM (1993) engine to Ruby. Play original WAD files with
4
+ the classic software renderer, a hardware rasterizer, or a GPU ray tracer, in
5
+ single-player or multiplayer.
4
6
 
5
7
  ![DOOM Ruby](demo.gif)
6
8
 
7
9
  ## Features
8
10
 
9
- - **Rendering**: BSP traversal, visplanes, drawsegs, sprite clipping, sky rendering matching Chocolate Doom
11
+ - **Three renderers**: Classic BSP software rendering, OpenGL rasterization, and GPU ray tracing
12
+ - **Dynamic lighting**: Projectile, explosion, emissive-floor, flashlight, shadow, and fog effects in the ray tracer
13
+ - **Multiplayer**: Authoritative server/client play and deterministic peer-to-peer lockstep, with co-op and deathmatch
14
+ - **Classic rendering**: BSP traversal, visplanes, drawsegs, sprite clipping, and sky rendering matching Chocolate Doom
10
15
  - **Combat**: Hitscan weapons (pistol, shotgun, chaingun), melee (fist, chainsaw), projectile rockets with splash damage
11
16
  - **Items**: Weapons, ammo, health, armor, keys -- all pickupable with correct DOOM behavior
12
17
  - **Movement**: Momentum-based physics with friction, smooth step transitions, wall sliding, view bob
@@ -50,14 +55,70 @@ doom /path/to/doom.wad
50
55
  | Space / E | Use (open doors) |
51
56
  | 1-7 | Switch weapons |
52
57
  | M | Toggle automap |
58
+ | R | Cycle classic, rasterizer, and ray-tracing renderers |
53
59
  | Z | Toggle debug overlay |
54
60
  | Escape | Release mouse / Quit |
55
61
 
62
+ ## Renderers
63
+
64
+ Select a renderer when starting the game:
65
+
66
+ ```bash
67
+ doom --renderer=classic /path/to/doom.wad
68
+ doom --renderer=rasterizer /path/to/doom.wad
69
+ doom --renderer=raytracing /path/to/doom.wad
70
+ ```
71
+
72
+ Press `R` during play to switch between them without restarting the simulation.
73
+
74
+ - `classic` is the pixel-accurate software BSP renderer.
75
+ - `rasterizer` builds a textured 3D world and draws it through Gosu's OpenGL context with a depth buffer.
76
+ - `raytracing` traces primary, shadow, reflection, refraction, and indirect-light rays on the GPU using a stackless BVH. It includes dynamic lights, hard shadows, emissive nukage, fog, and a flashlight. It does not require dedicated hardware ray-intersection units.
77
+
78
+ The ray tracer uses a hybrid raster pass for sprites. Its secondary rays are a
79
+ single-bounce approximation rather than a converged path-traced image. Fog, the
80
+ flashlight, and secondary rays can be toggled in the Options menu. See
81
+ [Modern renderers](docs/modern-renderer.md) for implementation details and
82
+ current limitations.
83
+
84
+ ## Multiplayer
85
+
86
+ For an authoritative session suitable for more players over the internet, run
87
+ a headless server and connect clients to it:
88
+
89
+ ```bash
90
+ doom --serve --players=4 --port=5029 /path/to/doom.wad
91
+ doom --join=server.example.com:5029 /path/to/doom.wad
92
+ ```
93
+
94
+ For a small LAN, the original-style deterministic lockstep mode is also
95
+ available:
96
+
97
+ ```bash
98
+ doom --host --players=2 --port=5029 /path/to/doom.wad
99
+ doom --connect=192.168.1.10:5029 /path/to/doom.wad
100
+ ```
101
+
102
+ Add `--deathmatch` to host or serve a deathmatch. `--frags=N` sets its frag
103
+ limit. Every participant must use the same WAD.
104
+
105
+ ## Frame rate
106
+
107
+ Frame generation is decoupled from presentation: the engine renders as fast as
108
+ it can and only presents to the display at its refresh rate, so the reported
109
+ FPS is not clamped to vsync. The debug overlay (`Z`) shows both numbers --
110
+ frames generated, and frames actually shown alongside the display's refresh
111
+ rate.
112
+
113
+ Turn it off with `UNCAPPED FPS` in the options menu, or start with `--vsync`
114
+ to present every frame the old way.
115
+
56
116
  ## Requirements
57
117
 
58
118
  - Ruby 3.1+ (Ruby 4.0 with YJIT recommended for best performance)
59
- - Gosu gem (for window/graphics)
119
+ - Gosu gem (for window, sound, and graphics)
60
120
  - SDL2 (native library required by Gosu)
121
+ - OpenGL 3.3 or newer for the rasterizer and ray tracer
61
122
 
62
123
  ### Installing SDL2
63
124
 
@@ -134,7 +195,7 @@ please purchase DOOM from [Steam](https://store.steampowered.com/app/2280/Ultima
134
195
 
135
196
  ## License
136
197
 
137
- GPL-2.0 -- Same license as the original DOOM source code.
198
+ GPL-2.0-only -- Same license as the original DOOM source code.
138
199
 
139
200
  ## Author
140
201
 
data/bin/doom CHANGED
@@ -2,56 +2,169 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  # Enable YJIT unless --no-yjit flag is passed (for live toggle demo)
5
- unless ARGV.delete('--no-yjit')
6
- if defined?(RubyVM::YJIT) && RubyVM::YJIT.respond_to?(:enable)
7
- RubyVM::YJIT.enable
8
- end
5
+ RubyVM::YJIT.enable if !ARGV.delete('--no-yjit') && defined?(RubyVM::YJIT) && RubyVM::YJIT.respond_to?(:enable)
6
+
7
+ # Headless benchmark mode. Skips loading the full game stack (Gosu, sound,
8
+ # menus, etc.) and runs the renderer in a tight loop. Self-contained: see
9
+ # lib/doom/benchmark.rb.
10
+ if ARGV.delete('--bench')
11
+ require_relative '../lib/doom/benchmark'
12
+ exit Doom::Benchmark.run(ARGV)
13
+ end
14
+
15
+ # Parse flags
16
+ rubykaigi_mode = ARGV.delete('--rubykaigi') || ARGV.delete('--kaigi')
17
+
18
+ # --native: run the real game with its renderer hot path compiled by
19
+ # spinel_native (needs the spinel compiler; set SPINEL=/path/to/bin/spinel).
20
+ ENV['DOOM_NATIVE'] = '1' if ARGV.delete('--native')
21
+
22
+ # Multiplayer flags, parsed before the heavy requires so a bad invocation
23
+ # fails fast rather than after a WAD load. Accepts --flag value and --flag=value.
24
+ def take_flag(name)
25
+ idx = ARGV.index { |a| a == name || a.start_with?(name + '=') }
26
+ return nil unless idx
27
+
28
+ arg = ARGV.delete_at(idx)
29
+ return arg.split('=', 2).last if arg.include?('=')
30
+
31
+ ARGV.delete_at(idx)
9
32
  end
10
33
 
34
+ renderer_name = take_flag('--renderer') || 'classic'
35
+
36
+ # Parse a flag that must be a positive integer; fall back to `default` (with a
37
+ # warning) if it is missing or malformed, so `--port foo` or `--frags -5` can't
38
+ # silently mis-configure the session.
39
+ def positive_int_flag(name, default)
40
+ raw = take_flag(name)
41
+ return default if raw.nil?
42
+
43
+ n = Integer(raw, exception: false)
44
+ return n if n&.positive?
45
+
46
+ warn "#{name}: expected a positive integer, got #{raw.inspect}; using #{default}"
47
+ default
48
+ end
49
+
50
+ net_host = ARGV.delete('--host')
51
+ net_connect = take_flag('--connect')
52
+ net_serve = ARGV.delete('--serve')
53
+ net_join = take_flag('--join')
54
+ net_port = positive_int_flag('--port', 5029)
55
+ net_players = positive_int_flag('--players', 2)
56
+ net_mode = ARGV.delete('--deathmatch') ? :deathmatch : :coop
57
+ # Frag limit: 0 or absent means play on forever. Every peer has to pass the
58
+ # same value -- it is not part of the handshake, only what the match reports.
59
+ # --frags is optional; absent or malformed means no limit (nil).
60
+ raw_frags = take_flag('--frags')
61
+ net_frags = raw_frags && Integer(raw_frags, exception: false)
62
+ net_frags = nil unless net_frags&.positive?
63
+
11
64
  # Parse arguments before loading heavy dependencies
12
- wad_path = ARGV[0]
65
+ wad_path = ARGV.find { |a| !a.start_with?('-') }
66
+
67
+ # Match the flags themselves, not the WAD argument: ARGV.find skips anything
68
+ # starting with a dash, so `doom --help` never matched and fell through to
69
+ # launching the game.
70
+ wants_help = ARGV.include?('-h') || ARGV.include?('--help')
71
+ wants_version = ARGV.include?('-v') || ARGV.include?('--version')
13
72
 
14
73
  # Show help (before loading anything)
15
- if wad_path == '-h' || wad_path == '--help'
16
- puts "DOOM - Ruby port of the classic 1993 game"
74
+ if wants_help
75
+ puts 'DOOM - Ruby port of the classic 1993 game'
76
+ puts
77
+ puts 'Usage: doom [OPTIONS] [WAD_FILE]'
78
+ puts
79
+ puts 'Options:'
80
+ puts ' -h, --help Show this help message'
81
+ puts ' -v, --version Show version'
82
+ puts ' --bench Run headless renderer benchmark (auto-downloads Freedoom)'
83
+ puts ' Subflags: --json --frames=N --warmup=N --map=E1M1 --wad=PATH'
84
+ puts ' --vsync Present every frame instead of uncapping generation'
85
+ puts ' --renderer NAME Renderer: classic, rasterizer, or raytracing (R cycles them)'
86
+ puts
87
+ puts 'Multiplayer, authoritative server (for many players over the internet):'
88
+ puts ' --serve Run a headless server others join (no window)'
89
+ puts ' --join HOST[:PORT] Join a server'
17
90
  puts
18
- puts "Usage: doom [OPTIONS] [WAD_FILE]"
91
+ puts 'Multiplayer, lockstep peer-to-peer (as in the original, for a small LAN):'
92
+ puts ' --host Host a game and wait for others to join'
93
+ puts ' --connect HOST[:PORT] Join a hosted game'
19
94
  puts
20
- puts "Options:"
21
- puts " -h, --help Show this help message"
22
- puts " -v, --version Show version"
95
+ puts ' --port N Port to serve/host on or join/connect to (default 5029)'
96
+ puts ' --players N Players to seat when serving or hosting (default 2)'
97
+ puts ' --deathmatch Deathmatch instead of co-op'
98
+ puts ' --frags N Frags to win a deathmatch (default: no limit)'
23
99
  puts
24
- puts "If no WAD file is specified, doom will look for doom1.wad in:"
25
- puts " 1. Current directory"
26
- puts " 2. ~/.doom/"
100
+ puts 'If no WAD file is specified, doom will look for doom1.wad in:'
101
+ puts ' 1. Current directory'
102
+ puts ' 2. ~/.doom/'
27
103
  puts
28
104
  puts "If not found, you'll be prompted to download the shareware version."
29
105
  puts
30
- puts "Controls:"
31
- puts " W/Up - Move forward"
32
- puts " S/Down - Move backward"
33
- puts " A - Strafe left"
34
- puts " D - Strafe right"
35
- puts " Left - Turn left"
36
- puts " Right - Turn right"
37
- puts " Mouse - Look around (click to capture)"
38
- puts " Escape - Release mouse / Quit"
106
+ puts 'Controls:'
107
+ puts ' W/Up - Move forward'
108
+ puts ' S/Down - Move backward'
109
+ puts ' A - Strafe left'
110
+ puts ' D - Strafe right'
111
+ puts ' Left - Turn left'
112
+ puts ' Right - Turn right'
113
+ puts ' Mouse - Look around (click to capture)'
114
+ puts ' Escape - Release mouse / Quit'
39
115
  exit 0
40
116
  end
41
117
 
42
- if wad_path == '-v' || wad_path == '--version'
118
+ if wants_version
43
119
  require_relative '../lib/doom/version'
44
120
  puts "DOOM Ruby v#{Doom::VERSION}"
45
121
  exit 0
46
122
  end
47
123
 
124
+ # TruffleRuby on macOS: its Gosu build is not linked with SDL2, and macOS ships
125
+ # only sdl2-compat, so preload it here (global) to satisfy Gosu's flat-namespace
126
+ # symbol lookups. The window also needs the Cocoa main thread, which only the
127
+ # JVM launch flag can give -- warn if it looks absent.
128
+ if defined?(RUBY_ENGINE) && RUBY_ENGINE == 'truffleruby' && RUBY_PLATFORM.include?('darwin')
129
+ begin
130
+ require 'fiddle'
131
+ %w[
132
+ /opt/homebrew/lib/libSDL2-2.0.0.dylib /opt/homebrew/lib/libSDL2.dylib
133
+ /usr/local/lib/libSDL2-2.0.0.dylib /usr/local/lib/libSDL2.dylib
134
+ ].each { |p| (Fiddle.dlopen(p); break) if File.exist?(p) }
135
+ rescue StandardError => e
136
+ warn "[doom] SDL2 preload for TruffleRuby failed (#{e.message}); the window may not open"
137
+ end
138
+ unless (ENV['JAVA_TOOL_OPTIONS'].to_s + ENV['TRUFFLERUBYOPT'].to_s).include?('XstartOnFirstThread')
139
+ warn "[doom] on TruffleRuby/macOS the window needs the main thread: run with " \
140
+ "`truffleruby --vm.XstartOnFirstThread bin/doom ...`"
141
+ end
142
+ end
143
+
48
144
  # Now load the full library
49
145
  require_relative '../lib/doom'
50
146
 
51
147
  begin
52
148
  # Find or download WAD
53
149
  wad_path = Doom::WadDownloader.ensure_wad_available(wad_path)
54
- Doom.run(wad_path)
150
+ net = if net_serve
151
+ # Authoritative server for a big session: no window, just the tic
152
+ # loop and the clients that join it.
153
+ { role: :serve, port: net_port, players: net_players, mode: net_mode }
154
+ elsif net_join
155
+ host, _, port = net_join.rpartition(':')
156
+ host = net_join if host.empty?
157
+ { role: :join, host: host, port: port.empty? ? net_port : port.to_i }
158
+ elsif net_host
159
+ { role: :host, port: net_port, players: net_players, mode: net_mode }
160
+ elsif net_connect
161
+ host, _, port = net_connect.rpartition(':')
162
+ host = net_connect if host.empty?
163
+ { role: :client, host: host, port: port.empty? ? net_port : port.to_i }
164
+ end
165
+
166
+ Doom.run(wad_path, rubykaigi: rubykaigi_mode, net: net, frag_limit: net_frags,
167
+ renderer: renderer_name)
55
168
  rescue Doom::WadDownloader::DownloadError => e
56
169
  puts "Error: #{e.message}"
57
170
  exit 1
@@ -0,0 +1,282 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Self-contained headless benchmark for the Doom-in-Ruby renderer.
4
+ #
5
+ # Designed to be extractable into a standalone benchmark repo: this file
6
+ # requires the Doom library (renderer + WAD readers) and otherwise depends
7
+ # only on Ruby's stdlib (net/http, json, zlib, fileutils).
8
+ #
9
+ # It auto-downloads Freedoom Phase 1 (~12MB zip, ~30MB extracted) on first
10
+ # run and caches it under ~/.doom/freedoom1.wad. No window is opened.
11
+ #
12
+ # Usage:
13
+ # bin/doom --bench
14
+ # bin/doom --bench --json
15
+ # bin/doom --bench --frames=500 --warmup=50
16
+ # bin/doom --bench --wad=/path/to/some.wad
17
+ # bin/doom --bench --map=E1M3
18
+
19
+ require 'json'
20
+ require 'net/http'
21
+ require 'uri'
22
+ require 'fileutils'
23
+ require 'zlib'
24
+
25
+ # Stub Gosu so the renderer's Platform module can load without the gem.
26
+ module Doom
27
+ module Platform
28
+ class GosuWindow; end unless defined?(GosuWindow)
29
+ end
30
+ end
31
+
32
+ require_relative 'wad/reader'
33
+ require_relative 'wad/palette'
34
+ require_relative 'wad/colormap'
35
+ require_relative 'wad/flat'
36
+ require_relative 'wad/patch'
37
+ require_relative 'wad/texture'
38
+ require_relative 'wad/sprite'
39
+ require_relative 'map/data'
40
+ require_relative 'game/player_state'
41
+ require_relative 'game/sector_actions'
42
+ require_relative 'render/renderer'
43
+
44
+ module Doom
45
+ module Benchmark
46
+ FREEDOOM_VERSION = '0.13.0'
47
+ FREEDOOM_URL =
48
+ 'https://github.com/freedoom/freedoom/releases/download/' \
49
+ "v#{FREEDOOM_VERSION}/freedoom-#{FREEDOOM_VERSION}.zip"
50
+ FREEDOOM_WAD_BASENAME = 'freedoom1.wad'
51
+ CACHE_DIR = File.join(Dir.home, '.doom')
52
+
53
+ DEFAULT_WARMUP = 30
54
+ DEFAULT_FRAMES = 200
55
+ DEFAULT_MAP = 'E1M1'
56
+
57
+ module_function
58
+
59
+ # Entry point. argv is what came after --bench.
60
+ # Returns process exit code.
61
+ def run(argv = [])
62
+ opts = parse_args(argv)
63
+ wad_path = opts[:wad] || ensure_freedoom_wad
64
+
65
+ game = load_game(wad_path, opts[:map])
66
+ result = bench_render(game, frames: opts[:frames], warmup: opts[:warmup])
67
+
68
+ result[:wad] = File.basename(wad_path)
69
+ result[:map] = opts[:map]
70
+ result[:ruby] = RUBY_DESCRIPTION
71
+ result[:jit] = jit_status
72
+
73
+ print_result(result, json: opts[:json])
74
+ 0
75
+ rescue StandardError => e
76
+ warn "benchmark failed: #{e.class}: #{e.message}"
77
+ warn e.backtrace.first(10).join("\n")
78
+ 1
79
+ end
80
+
81
+ def parse_args(argv)
82
+ opts = {
83
+ warmup: DEFAULT_WARMUP,
84
+ frames: DEFAULT_FRAMES,
85
+ map: DEFAULT_MAP,
86
+ wad: nil,
87
+ json: false
88
+ }
89
+ argv.each do |arg|
90
+ case arg
91
+ when '--json' then opts[:json] = true
92
+ when /\A--frames=(\d+)\z/ then opts[:frames] = Regexp.last_match(1).to_i
93
+ when /\A--warmup=(\d+)\z/ then opts[:warmup] = Regexp.last_match(1).to_i
94
+ when /\A--map=(\w+)\z/ then opts[:map] = Regexp.last_match(1).upcase
95
+ when /\A--wad=(.+)\z/ then opts[:wad] = Regexp.last_match(1)
96
+ end
97
+ end
98
+ opts
99
+ end
100
+
101
+ def jit_status
102
+ if defined?(RubyVM::YJIT) && RubyVM::YJIT.respond_to?(:enabled?) && RubyVM::YJIT.enabled?
103
+ 'YJIT'
104
+ elsif defined?(RubyVM::ZJIT) && RubyVM::ZJIT.respond_to?(:enabled?) && RubyVM::ZJIT.enabled?
105
+ 'ZJIT'
106
+ else
107
+ 'OFF'
108
+ end
109
+ end
110
+
111
+ def ensure_freedoom_wad
112
+ cached = File.join(CACHE_DIR, FREEDOOM_WAD_BASENAME)
113
+ return cached if File.exist?(cached)
114
+
115
+ FileUtils.mkdir_p(CACHE_DIR)
116
+ tmp_zip = File.join(CACHE_DIR, "freedoom-#{FREEDOOM_VERSION}.zip.part")
117
+
118
+ warn "Downloading Freedoom Phase 1 v#{FREEDOOM_VERSION} (~12 MB)..."
119
+ download_file(FREEDOOM_URL, tmp_zip)
120
+
121
+ warn "Extracting #{FREEDOOM_WAD_BASENAME}..."
122
+ extract_from_zip(tmp_zip, FREEDOOM_WAD_BASENAME, cached)
123
+ File.delete(tmp_zip) if File.exist?(tmp_zip)
124
+
125
+ cached
126
+ end
127
+
128
+ # Download URL to dest, following redirects.
129
+ def download_file(url, dest, redirect_limit: 5)
130
+ raise 'too many redirects' if redirect_limit < 0
131
+
132
+ uri = URI.parse(url)
133
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
134
+ http.request_get(uri.request_uri) do |resp|
135
+ case resp
136
+ when Net::HTTPSuccess
137
+ File.open(dest, 'wb') { |f| resp.read_body { |chunk| f.write(chunk) } }
138
+ when Net::HTTPRedirection
139
+ return download_file(resp['location'], dest, redirect_limit: redirect_limit - 1)
140
+ else
141
+ raise "HTTP #{resp.code} fetching #{url}"
142
+ end
143
+ end
144
+ end
145
+ end
146
+
147
+ # Minimal ZIP reader: locate one named file in a ZIP archive and extract
148
+ # it to dest. Supports stored (method 0) and deflated (method 8) entries.
149
+ # No ZIP64, no encryption, no spanning. Sufficient for the Freedoom zip.
150
+ def extract_from_zip(zip_path, target_basename, dest)
151
+ data = File.binread(zip_path)
152
+
153
+ eocd = find_eocd(data) or raise "ZIP end-of-central-directory not found in #{zip_path}"
154
+ _, _, _, _, _, _, cdir_offset, = data.byteslice(eocd, 22).unpack('VvvvvVVv')
155
+
156
+ ptr = cdir_offset
157
+ while data.byteslice(ptr, 4) == "PK\x01\x02".b
158
+ fields = data.byteslice(ptr, 46).unpack('VvvvvvvVVVvvvvvVV')
159
+ method = fields[4]
160
+ csize = fields[8]
161
+ fname_len = fields[10]
162
+ extra_len = fields[11]
163
+ comment_len = fields[12]
164
+ local_offset = fields[16]
165
+ fname = data.byteslice(ptr + 46, fname_len)
166
+
167
+ if File.basename(fname) == target_basename
168
+ lh = data.byteslice(local_offset, 30).unpack('VvvvvvVVVvv')
169
+ lh_fname_len = lh[9]
170
+ lh_extra_len = lh[10]
171
+ data_offset = local_offset + 30 + lh_fname_len + lh_extra_len
172
+ compressed = data.byteslice(data_offset, csize)
173
+
174
+ content = case method
175
+ when 0 then compressed
176
+ when 8 then Zlib::Inflate.new(-Zlib::MAX_WBITS).inflate(compressed)
177
+ else raise "unsupported ZIP compression method #{method}"
178
+ end
179
+
180
+ File.binwrite(dest, content)
181
+ return
182
+ end
183
+
184
+ ptr += 46 + fname_len + extra_len + comment_len
185
+ end
186
+
187
+ raise "#{target_basename} not found in #{zip_path}"
188
+ end
189
+
190
+ # EOCD record sits at the end of the file. Search backwards through the
191
+ # last 64KB (max comment size).
192
+ def find_eocd(data)
193
+ sig = "PK\x05\x06".b
194
+ max = data.size - 22
195
+ min = [max - 65_536, 0].max
196
+ max.downto(min) { |i| return i if data.byteslice(i, 4) == sig }
197
+ nil
198
+ end
199
+
200
+ def load_game(wad_path, map_name)
201
+ wad = Doom::Wad::Reader.new(wad_path)
202
+ palette = Doom::Wad::Palette.load(wad)
203
+ colormap = Doom::Wad::Colormap.load(wad)
204
+ flats = Doom::Wad::Flat.load_all(wad)
205
+ textures = Doom::Wad::TextureManager.new(wad)
206
+ sprites = Doom::Wad::SpriteManager.new(wad)
207
+ map = Doom::Map::MapData.load(wad, map_name)
208
+
209
+ renderer = Doom::Render::Renderer.new(
210
+ wad, map, textures, palette, colormap, flats, sprites
211
+ )
212
+ renderer.skip_background_fill = true
213
+
214
+ ps = map.player_start
215
+ renderer.set_player(ps.x, ps.y, 41, ps.angle)
216
+
217
+ { renderer: renderer, player_start: ps }
218
+ end
219
+
220
+ def bench_render(game, frames:, warmup:)
221
+ renderer = game[:renderer]
222
+
223
+ warmup.times { renderer.render_frame }
224
+
225
+ GC.start
226
+ GC.compact if GC.respond_to?(:compact)
227
+
228
+ gc_before = total_allocs
229
+ times = Array.new(frames)
230
+ frames.times do |i|
231
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
232
+ renderer.render_frame
233
+ t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
234
+ times[i] = t1 - t0
235
+ end
236
+ gc_after = total_allocs
237
+
238
+ sorted = times.sort
239
+ total = times.sum
240
+ {
241
+ frames: frames,
242
+ warmup: warmup,
243
+ avg_ms: (total / frames) * 1000,
244
+ median_ms: sorted[frames / 2] * 1000,
245
+ p95_ms: sorted[(frames * 0.95).to_i] * 1000,
246
+ p99_ms: sorted[(frames * 0.99).to_i] * 1000,
247
+ min_ms: sorted.first * 1000,
248
+ max_ms: sorted.last * 1000,
249
+ fps: frames / total,
250
+ allocs_per_frame: (gc_after - gc_before).to_f / frames
251
+ }
252
+ end
253
+
254
+ def total_allocs
255
+ GC.stat[:total_allocated_objects] || 0
256
+ rescue StandardError
257
+ 0
258
+ end
259
+
260
+ def print_result(r, json:)
261
+ if json
262
+ puts JSON.generate(r)
263
+ return
264
+ end
265
+
266
+ puts 'DOOM-Ruby benchmark'
267
+ puts " WAD: #{r[:wad]} (#{r[:map]})"
268
+ puts " Ruby: #{r[:ruby]}"
269
+ puts " JIT: #{r[:jit]}"
270
+ puts ''
271
+ puts "Performance (#{r[:frames]} frames after #{r[:warmup]} warmup):"
272
+ puts ' avg %.2f ms' % r[:avg_ms]
273
+ puts ' median %.2f ms' % r[:median_ms]
274
+ puts ' p95 %.2f ms' % r[:p95_ms]
275
+ puts ' p99 %.2f ms' % r[:p99_ms]
276
+ puts ' min %.2f ms' % r[:min_ms]
277
+ puts ' max %.2f ms' % r[:max_ms]
278
+ puts ' fps %.1f' % r[:fps]
279
+ puts ' allocs/frame %.0f' % r[:allocs_per_frame] if r[:allocs_per_frame] > 0
280
+ end
281
+ end
282
+ end
@@ -37,7 +37,7 @@ module Doom
37
37
  [true, 'BFALL1', 'BFALL4'],
38
38
  [true, 'SFALL1', 'SFALL4'],
39
39
  [true, 'WFALL1', 'WFALL4'],
40
- [true, 'DBRAIN1', 'DBRAIN4'],
40
+ [true, 'DBRAIN1', 'DBRAIN4']
41
41
  ].freeze
42
42
 
43
43
  attr_reader :flat_translation, :texture_translation
@@ -61,7 +61,7 @@ module Doom
61
61
  @anims << {
62
62
  is_texture: is_texture,
63
63
  frames: frames,
64
- speed: TICS_PER_FRAME,
64
+ speed: TICS_PER_FRAME
65
65
  }
66
66
  end
67
67
  end
@@ -92,6 +92,13 @@ module Doom
92
92
  def translate_texture(name)
93
93
  @texture_translation[name] || name
94
94
  end
95
+
96
+ # Every frame that may be selected later. Renderers use this while
97
+ # building immutable texture atlases, including when the first draw
98
+ # happens in a menu before update has populated the translations.
99
+ def frame_names
100
+ @anims.flat_map { |animation| animation[:frames] }.uniq
101
+ end
95
102
  end
96
103
  end
97
104
  end