quake-rb 0.1.0 → 0.2.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.
@@ -8,6 +8,9 @@ module Quake
8
8
  class Reader
9
9
  IDPOLYHEADER = 0x4F504449 # "IDPO" in little-endian
10
10
  MDL_VERSION = 6
11
+ MAX_LBM_HEIGHT = 480
12
+ MAXALIASVERTS = 2000
13
+ ALIAS_BASE_SIZE_RATIO = 1.0 / 11.0
11
14
 
12
15
  def initialize(data)
13
16
  @data = data
@@ -16,7 +19,8 @@ module Quake
16
19
 
17
20
  def parse
18
21
  header = read_header
19
- skins = read_skins(header)
22
+ validate_header!(header)
23
+ skins, skin_intervals = read_skins(header)
20
24
  stverts = read_stverts(header[:numverts])
21
25
  triangles = read_triangles(header[:numtris])
22
26
  frames = read_frames(header[:numframes], header[:numverts])
@@ -29,11 +33,15 @@ module Quake
29
33
  skin_width: header[:skinwidth],
30
34
  skin_height: header[:skinheight],
31
35
  skins: skins,
36
+ skin_intervals: skin_intervals,
32
37
  stverts: stverts,
33
38
  triangles: triangles,
34
39
  frames: frames,
35
40
  flags: header[:flags],
36
- sync_type: header[:synctype]
41
+ sync_type: header[:synctype],
42
+ size: header[:size] * ALIAS_BASE_SIZE_RATIO,
43
+ mins: Math::Vec3.new(-16.0, -16.0, -16.0),
44
+ maxs: Math::Vec3.new(16.0, 16.0, 16.0)
37
45
  )
38
46
  end
39
47
 
@@ -42,7 +50,9 @@ module Quake
42
50
  def read_header
43
51
  ident, version = read_fmt("VV")
44
52
  raise "Not an MDL file (ident=0x#{ident.to_s(16)})" unless ident == IDPOLYHEADER
45
- raise "Unsupported MDL version #{version}" unless version == MDL_VERSION
53
+ unless version == MDL_VERSION
54
+ raise "Mod_LoadAliasModel: wrong version number (#{version} should be #{MDL_VERSION})"
55
+ end
46
56
 
47
57
  sx, sy, sz = read_fmt("eee")
48
58
  ox, oy, oz = read_fmt("eee")
@@ -70,8 +80,35 @@ module Quake
70
80
  }
71
81
  end
72
82
 
83
+ def validate_header!(header)
84
+ if header[:skinheight] > MAX_LBM_HEIGHT
85
+ raise "model has a skin taller than #{MAX_LBM_HEIGHT}"
86
+ end
87
+
88
+ raise "model has no vertices" if header[:numverts] <= 0
89
+
90
+ if header[:numverts] > MAXALIASVERTS
91
+ raise "model has too many vertices"
92
+ end
93
+
94
+ raise "model has no triangles" if header[:numtris] <= 0
95
+
96
+ if (header[:skinwidth] & 0x03) != 0
97
+ raise "Mod_LoadAliasModel: skinwidth not multiple of 4"
98
+ end
99
+
100
+ if header[:numskins] < 1
101
+ raise "Mod_LoadAliasModel: Invalid # of skins: #{header[:numskins]}"
102
+ end
103
+
104
+ if header[:numframes] < 1
105
+ raise "Mod_LoadAliasModel: Invalid # of frames: #{header[:numframes]}"
106
+ end
107
+ end
108
+
73
109
  def read_skins(header)
74
110
  skins = []
111
+ skin_intervals = []
75
112
  skin_size = header[:skinwidth] * header[:skinheight]
76
113
 
77
114
  header[:numskins].times do
@@ -80,21 +117,62 @@ module Quake
80
117
  if skin_type == 0 # ALIAS_SKIN_SINGLE
81
118
  pixels = @data[@pos, skin_size]
82
119
  @pos += skin_size
83
- skins << [pixels]
120
+ skins << [flood_fill_skin(pixels, header[:skinwidth], header[:skinheight])]
121
+ skin_intervals << nil
84
122
  else # ALIAS_SKIN_GROUP
85
123
  num_skins = read_fmt("V").first
86
- _intervals = read_fmt("e" * num_skins) # timing intervals
124
+ intervals = read_fmt("e" * num_skins)
125
+ if intervals.any? { |interval| interval <= 0.0 }
126
+ raise "Mod_LoadAliasSkinGroup: interval<=0"
127
+ end
87
128
  group = []
88
129
  num_skins.times do
89
130
  pixels = @data[@pos, skin_size]
90
131
  @pos += skin_size
91
- group << pixels
132
+ group << flood_fill_skin(pixels, header[:skinwidth], header[:skinheight])
92
133
  end
93
134
  skins << group
135
+ skin_intervals << intervals
94
136
  end
95
137
  end
96
138
 
97
- skins
139
+ [skins, skin_intervals]
140
+ end
141
+
142
+ def flood_fill_skin(pixels, skinwidth, skinheight)
143
+ fillcolor = pixels.getbyte(0)
144
+ filledcolor = 0
145
+ return pixels if fillcolor == filledcolor || fillcolor == 255
146
+
147
+ skin = pixels.dup
148
+ fifo = [[0, 0]]
149
+ outpt = 0
150
+
151
+ while outpt < fifo.size
152
+ x, y = fifo[outpt]
153
+ outpt += 1
154
+ fdc = filledcolor
155
+ offset = x + skinwidth * y
156
+
157
+ [[-1, 0], [1, 0], [0, -1], [0, 1]].each do |dx, dy|
158
+ nx = x + dx
159
+ ny = y + dy
160
+ next if nx.negative? || nx >= skinwidth || ny.negative? || ny >= skinheight
161
+
162
+ neighbor = nx + skinwidth * ny
163
+ color = skin.getbyte(neighbor)
164
+ if color == fillcolor
165
+ skin.setbyte(neighbor, 255)
166
+ fifo << [nx, ny]
167
+ elsif color != 255
168
+ fdc = color
169
+ end
170
+ end
171
+
172
+ skin.setbyte(offset, fdc)
173
+ end
174
+
175
+ skin
98
176
  end
99
177
 
100
178
  def read_stverts(count)
@@ -141,6 +219,9 @@ module Quake
141
219
  _bbox_max = read_trivertx
142
220
 
143
221
  intervals = read_fmt("e" * num_frames)
222
+ if intervals.any? { |interval| interval <= 0.0 }
223
+ raise "Mod_LoadAliasGroup: interval<=0"
224
+ end
144
225
 
145
226
  frames = num_frames.times.map do
146
227
  read_single_frame(numverts)
@@ -20,8 +20,8 @@ module Quake
20
20
  # Complete MDL model
21
21
  Model = Data.define(
22
22
  :scale, :scale_origin, :bounding_radius, :eye_position,
23
- :skin_width, :skin_height, :skins,
24
- :stverts, :triangles, :frames, :flags, :sync_type
23
+ :skin_width, :skin_height, :skins, :skin_intervals,
24
+ :stverts, :triangles, :frames, :flags, :sync_type, :size, :mins, :maxs
25
25
  )
26
26
 
27
27
  ALIAS_ONSEAM = 0x0020
@@ -8,6 +8,7 @@ module Quake
8
8
  MAGIC = "PACK"
9
9
  HEADER_SIZE = 12
10
10
  DIR_ENTRY_SIZE = 64
11
+ MAX_FILES_IN_PACK = 2048
11
12
 
12
13
  attr_reader :entries
13
14
 
@@ -23,7 +24,7 @@ module Quake
23
24
  end
24
25
 
25
26
  def read(name)
26
- entry = @entries[name] || @entries[name.downcase]
27
+ entry = @entries[name]
27
28
  raise "File not found in PAK: #{name}" unless entry
28
29
 
29
30
  @io.seek(entry.offset)
@@ -39,9 +40,12 @@ module Quake
39
40
  def parse_header
40
41
  header = @io.read(HEADER_SIZE)
41
42
  magic, dirofs, dirlen = header.unpack("a4V2")
42
- raise "Not a PAK file: #{magic.inspect}" unless magic == MAGIC
43
+ raise "#{@path} is not a packfile" unless magic == MAGIC
43
44
 
44
45
  num_entries = dirlen / DIR_ENTRY_SIZE
46
+ if num_entries > MAX_FILES_IN_PACK
47
+ raise "#{@path} has #{num_entries} files"
48
+ end
45
49
  @io.seek(dirofs)
46
50
  dir_data = @io.read(dirlen)
47
51
 
@@ -49,7 +53,9 @@ module Quake
49
53
  offset = i * DIR_ENTRY_SIZE
50
54
  raw = dir_data[offset, DIR_ENTRY_SIZE]
51
55
  name, filepos, filelen = raw.unpack("Z56V2")
52
- @entries[name.downcase] = Entry.new(name: name, offset: filepos, size: filelen)
56
+ next if @entries.key?(name)
57
+
58
+ @entries[name] = Entry.new(name: name, offset: filepos, size: filelen)
53
59
  end
54
60
  end
55
61
  end
data/lib/quake/palette.rb CHANGED
@@ -17,13 +17,12 @@ module Quake
17
17
  @rgb[index]
18
18
  end
19
19
 
20
- # Convert an array of 8-bit indexed pixels to RGBA bytes
21
- # Last palette entry (255) is typically transparent
22
- def indexed_to_rgba(pixels, transparent_index: 255)
20
+ # Convert an array of 8-bit indexed pixels to RGBA bytes.
21
+ def indexed_to_rgba(pixels, transparent_index: nil)
23
22
  rgba = String.new(capacity: pixels.bytesize * 4)
24
23
  pixels.each_byte do |idx|
25
24
  r, g, b = @rgb[idx]
26
- a = (idx == transparent_index) ? 0 : 255
25
+ a = (transparent_index && idx == transparent_index) ? 0 : 255
27
26
  rgba << r << g << b << a
28
27
  end
29
28
  rgba
@@ -19,10 +19,37 @@ module Quake
19
19
  CONTENTS_WATER = -3
20
20
  CONTENTS_SLIME = -4
21
21
  CONTENTS_LAVA = -5
22
+ CONTENTS_SKY = -6
22
23
 
23
24
  DIST_EPSILON = 0.03125 # 1/32, same as Quake
25
+ BoxHull = Data.define(:clipnodes, :planes, :first_clipnode, :last_clipnode)
24
26
 
25
27
  module HullTrace
28
+ def self.hull_for_box(mins, maxs)
29
+ clipnodes = 6.times.map do |i|
30
+ side = i & 1
31
+ children = Array.new(2)
32
+ children[side] = CONTENTS_EMPTY
33
+ children[side ^ 1] = i == 5 ? CONTENTS_SOLID : i + 1
34
+
35
+ Quake::Bsp::ClipNode.new(plane_index: i, children: children)
36
+ end
37
+
38
+ planes = [
39
+ box_plane(type: 0, dist: maxs.x),
40
+ box_plane(type: 0, dist: mins.x),
41
+ box_plane(type: 1, dist: maxs.y),
42
+ box_plane(type: 1, dist: mins.y),
43
+ box_plane(type: 2, dist: maxs.z),
44
+ box_plane(type: 2, dist: mins.z)
45
+ ]
46
+
47
+ BoxHull.new(
48
+ clipnodes: clipnodes, planes: planes,
49
+ first_clipnode: 0, last_clipnode: 5
50
+ )
51
+ end
52
+
26
53
  # Check what content type a point is in, using the clipnode tree.
27
54
  # hull_clipnodes: array of ClipNode
28
55
  # planes: array of Plane
@@ -30,10 +57,14 @@ module Quake
30
57
  # point: Vec3
31
58
  def self.point_contents(clipnodes, planes, node_index, point)
32
59
  while node_index >= 0
60
+ if node_index >= clipnodes.size
61
+ raise "SV_HullPointContents: bad node number"
62
+ end
63
+
33
64
  node = clipnodes[node_index]
34
65
  plane = planes[node.plane_index]
35
66
 
36
- dist = point.dot(plane.normal) - plane.dist
67
+ dist = plane_distance(plane, point)
37
68
  node_index = dist >= 0 ? node.children[0] : node.children[1]
38
69
  end
39
70
  node_index # negative value = content type
@@ -64,11 +95,29 @@ module Quake
64
95
  TraceResult.new(**result)
65
96
  end
66
97
 
98
+ # Quake's Mod_MakeHull0: duplicate the render node tree as clipnodes so
99
+ # point traces (hull 0) can reuse the same recursive hull check. A node
100
+ # child < 0 encodes leaf -(child + 1); hull 0 replaces it with the leaf
101
+ # contents, exactly like the C loader.
102
+ def self.hull0_clipnodes(level)
103
+ return @hull0_clipnodes if @hull0_level&.equal?(level)
104
+
105
+ @hull0_level = level
106
+ @hull0_clipnodes = level.nodes.map do |node|
107
+ children = node.children.map do |child|
108
+ child >= 0 ? child : level.leafs[-child - 1].contents
109
+ end
110
+ Quake::Bsp::ClipNode.new(plane_index: node.plane_index, children: children)
111
+ end
112
+ end
113
+
67
114
  # Trace against the world AND all solid brush entities, returning the
68
115
  # nearest hit. Brush entity traces are done in entity-local space
69
116
  # (subtract origin, trace sub-model hull, transform back).
117
+ # hull_num 0 is a point trace through the render-node hull, matching
118
+ # Quake's SV_TraceLine with MOVE_NOMONSTERS (world + SOLID_BSP only).
70
119
  def self.trace_world_and_entities(level, p1, p2, brush_entities, hull_num: 1)
71
- clipnodes = level.clipnodes
120
+ clipnodes = hull_num.zero? ? hull0_clipnodes(level) : level.clipnodes
72
121
  planes = level.planes
73
122
 
74
123
  # World trace
@@ -79,9 +128,11 @@ module Quake
79
128
  # Trace against each brush entity's sub-model
80
129
  brush_entities&.each do |ent|
81
130
  next unless ent.brush_entity?
131
+ next if ent.removed?
82
132
  # Triggers are SOLID_TRIGGER in Quake: they detect overlap but
83
133
  # don't block movement. They're handled by check_triggers.
84
134
  next if ent.classname.start_with?("trigger_")
135
+ next if ent.classname == "func_illusionary"
85
136
  model = level.models[ent.model_index]
86
137
  next unless model
87
138
 
@@ -131,11 +182,15 @@ module Quake
131
182
  return
132
183
  end
133
184
 
185
+ if node_index >= clipnodes.size
186
+ raise "SV_RecursiveHullCheck: bad node number"
187
+ end
188
+
134
189
  node = clipnodes[node_index]
135
190
  plane = planes[node.plane_index]
136
191
 
137
- d1 = p1.dot(plane.normal) - plane.dist
138
- d2 = p2.dot(plane.normal) - plane.dist
192
+ d1 = plane_distance(plane, p1)
193
+ d2 = plane_distance(plane, p2)
139
194
 
140
195
  # Both on same side?
141
196
  if d1 >= 0 && d2 >= 0
@@ -188,6 +243,24 @@ module Quake
188
243
  end
189
244
  end
190
245
  end
246
+
247
+ private_class_method def self.plane_distance(plane, point)
248
+ case plane.type
249
+ when 0 then point.x - plane.dist
250
+ when 1 then point.y - plane.dist
251
+ when 2 then point.z - plane.dist
252
+ else point.dot(plane.normal) - plane.dist
253
+ end
254
+ end
255
+
256
+ private_class_method def self.box_plane(type:, dist:)
257
+ normal = case type
258
+ when 0 then Math::Vec3.new(1.0, 0.0, 0.0)
259
+ when 1 then Math::Vec3.new(0.0, 1.0, 0.0)
260
+ else Math::Vec3.new(0.0, 0.0, 1.0)
261
+ end
262
+ Quake::Bsp::Plane.new(normal: normal, dist: dist, type: type)
263
+ end
191
264
  end
192
265
  end
193
266
  end