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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e8f1be6b4e908fb60c850e5ac7e4e9d5ade8fff60fd7c9744d89da3132625e5a
|
|
4
|
+
data.tar.gz: bdbc30dcb9141292e4c6ae8d974b604b0d8cdb440dac964bc637615fd9cf53b4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|

|
|
6
8
|
|
|
7
9
|
## Features
|
|
8
10
|
|
|
9
|
-
- **
|
|
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
|
|
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,59 +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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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)
|
|
9
13
|
end
|
|
10
14
|
|
|
11
15
|
# Parse flags
|
|
12
16
|
rubykaigi_mode = ARGV.delete('--rubykaigi') || ARGV.delete('--kaigi')
|
|
13
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)
|
|
32
|
+
end
|
|
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
|
+
|
|
14
64
|
# Parse arguments before loading heavy dependencies
|
|
15
65
|
wad_path = ARGV.find { |a| !a.start_with?('-') }
|
|
16
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')
|
|
72
|
+
|
|
17
73
|
# Show help (before loading anything)
|
|
18
|
-
if
|
|
19
|
-
puts
|
|
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'
|
|
20
90
|
puts
|
|
21
|
-
puts
|
|
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'
|
|
22
94
|
puts
|
|
23
|
-
puts
|
|
24
|
-
puts
|
|
25
|
-
puts
|
|
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)'
|
|
26
99
|
puts
|
|
27
|
-
puts
|
|
28
|
-
puts
|
|
29
|
-
puts
|
|
100
|
+
puts 'If no WAD file is specified, doom will look for doom1.wad in:'
|
|
101
|
+
puts ' 1. Current directory'
|
|
102
|
+
puts ' 2. ~/.doom/'
|
|
30
103
|
puts
|
|
31
104
|
puts "If not found, you'll be prompted to download the shareware version."
|
|
32
105
|
puts
|
|
33
|
-
puts
|
|
34
|
-
puts
|
|
35
|
-
puts
|
|
36
|
-
puts
|
|
37
|
-
puts
|
|
38
|
-
puts
|
|
39
|
-
puts
|
|
40
|
-
puts
|
|
41
|
-
puts
|
|
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'
|
|
42
115
|
exit 0
|
|
43
116
|
end
|
|
44
117
|
|
|
45
|
-
if
|
|
118
|
+
if wants_version
|
|
46
119
|
require_relative '../lib/doom/version'
|
|
47
120
|
puts "DOOM Ruby v#{Doom::VERSION}"
|
|
48
121
|
exit 0
|
|
49
122
|
end
|
|
50
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
|
+
|
|
51
144
|
# Now load the full library
|
|
52
145
|
require_relative '../lib/doom'
|
|
53
146
|
|
|
54
147
|
begin
|
|
55
148
|
# Find or download WAD
|
|
56
149
|
wad_path = Doom::WadDownloader.ensure_wad_available(wad_path)
|
|
57
|
-
|
|
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)
|
|
58
168
|
rescue Doom::WadDownloader::DownloadError => e
|
|
59
169
|
puts "Error: #{e.message}"
|
|
60
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
|
data/lib/doom/game/animations.rb
CHANGED
|
@@ -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
|