├── README.md
├── file-spr
├── file-spr.py
└── spr.py
└── img
├── 1.png
└── 2.png
/README.md:
--------------------------------------------------------------------------------
1 | # Half-Life sprite plugin for GIMP
2 | GIMP plugin that allows you to import and export sprites from a Half-Life game. This plugin can also open new version sprites containing DDS textures used in Counter-Strike Online (*Experimantal feature*).
3 |
4 |
5 | ## Requirements
6 | 1. [GIMP](https://www.gimp.org/), recommended GIMP version >= 2.10.
7 | 2. GIMP's python module gimpfu with [patch](https://gitlab.gnome.org/GNOME/gimp/-/blob/5557ad8ac7708d3b062f885e1609c082dfb1710d/plug-ins/pygimp/gimpfu.py). Note: for **Arch Linux** you should use [python2-gimp](https://aur.archlinux.org/packages/python2-gimp) instead.
8 |
9 | ## Installation
10 | Download and extract the `file-spr` folder to GIMP's `plug-ins` folder:
11 | **Windows**: `C:\Users\\AppData\Roaming\GIMP\2.10\plug-ins`
12 | **Linux**: `/home//.config/GIMP/2.10/plug-ins`
13 | **macOS**: `/Users//Library/Application Support/GIMP/2.10/plug-ins`
14 |
15 | *If you can’t locate the `plug-ins` folder, open GIMP and go to Edit > Preferences > Folders > Plug-Ins and use one of the listed folders.*
16 |
17 | ## Usage
18 | To import `.spr` file into GIMP, go to File > Open.
19 |
20 | To export image as `.spr` file from GIMP, go to File > Export As then enter the file name with `*.spr` extension and click *Export*.
21 | In the dialog box that appears, you can configure the exported sprite.
22 |
23 |
24 |
25 | In this version of the plugin, export of frame groups is supported only if you have previously imported a sprite containing frame groups and have not changed the color indexing mode. Otherwise, the layer groups will be merged.
26 |
27 | ## See also
28 | [GIMP plugin for converting an image to Half-Life alphatest mode](https://github.com/Psycrow101/GIMP-hl-alphatest-plugin)
29 |
--------------------------------------------------------------------------------
/file-spr/file-spr.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python2
2 | # GIMP plugin for the Half-Life sprite format (.spr)
3 |
4 | # TODO: Full support group frames
5 |
6 | from gimpfu import *
7 | import gimpui
8 | import os, sys
9 |
10 | sys.path.append(os.path.dirname(os.path.abspath(__file__)))
11 |
12 | from struct import pack, unpack
13 | from spr import Sprite
14 |
15 | t = gettext.translation('gimp20-python', gimp.locale_directory, fallback=True)
16 | ugettext = t.ugettext
17 |
18 | AUTHOR = 'Psycrow'
19 | COPYRIGHT = AUTHOR
20 | COPYRIGHT_YEAR = '2020'
21 |
22 | EDITOR_PROC = 'hl-spr-export-dialog'
23 | LOAD_PROC = 'file-hl-spr-load'
24 | LOAD_THUMB_PROC = 'file-hl-spr-load-thumb'
25 | SAVE_PROC = 'file-hl-spr-save'
26 |
27 |
28 | def load_spr_thumbnail(file_path, thumb_size):
29 | img = Sprite.load_from_file(file_path, True)[0]
30 | width, height = img.width, img.height
31 | scale = float(thumb_size) / max(width, height)
32 | if scale and scale != 1.0:
33 | width = int(width * scale)
34 | height = int(height * scale)
35 | pdb.gimp_image_scale(img, width, height)
36 |
37 | return (img, width, height)
38 |
39 |
40 | def load_spr(file_path, raw_filename):
41 | try:
42 | images = Sprite.load_from_file(file_path)
43 | for img in images[:-1]:
44 | gimp.Display(img)
45 | gimp.displays_flush()
46 | return images[-1]
47 | except Exception as e:
48 | fail('Error loading sprite file:\n\n%s!' % e.message)
49 |
50 |
51 | def save_spr(img, drawable, filename, raw_filename):
52 | from array import array
53 | import pygtk
54 | import gtk
55 | pygtk.require('2.0')
56 |
57 | THUMB_MAXSIZE = 128
58 | RESPONSE_EXPORT = 1
59 | MIN_FRAME_ORIGIN = -8192
60 | MAX_FRAME_ORIGIN = 8192
61 | LS_LAYER, LS_PIXBUF, LS_SIZE_INFO, LS_EXPORT, LS_ORIGIN_X, LS_ORIGIN_Y, LS_THUMBDATA = range(7)
62 |
63 | spr_img = img.duplicate()
64 | gimpui.gimp_ui_init()
65 |
66 | if spr_img.base_type != INDEXED:
67 | try:
68 | pdb.gimp_convert_indexed(spr_img, NO_DITHER, MAKE_PALETTE, 256, 0, 0, '')
69 | except RuntimeError:
70 | # Gimp does not support indexed mode if the image contains layer groups, so delete them
71 | for layer in spr_img.layers:
72 | if pdb.gimp_item_is_group(layer):
73 | pdb.gimp_image_merge_layer_group(spr_img, layer)
74 | pdb.gimp_convert_indexed(spr_img, NO_DITHER, MAKE_PALETTE, 256, 0, 0, '')
75 |
76 | def make_thumbnail_data(layer):
77 | width = layer.width
78 | height = layer.height
79 |
80 | indices = layer.get_pixel_rgn(0, 0, width, height)[:, :]
81 | if layer.type == INDEXEDA_IMAGE:
82 | indices = indices[::2]
83 | indices = ''.join(i + i for i in indices)
84 |
85 | thumbnail_layer = gimp.Layer(spr_img, layer.name + '_temp', width, height, INDEXEDA_IMAGE, 100, NORMAL_MODE)
86 | thumbnail_rgn = thumbnail_layer.get_pixel_rgn(0, 0, width, height)
87 | thumbnail_rgn[:, :] = indices
88 | pdb.gimp_image_insert_layer(spr_img, thumbnail_layer, None, 0)
89 |
90 | scale = float(THUMB_MAXSIZE) / max(width, height)
91 | if scale < 1.0:
92 | width = max(int(width * scale), 1)
93 | height = max(int(height * scale), 1)
94 | pdb.gimp_layer_scale_full(thumbnail_layer, width, height, False, 0)
95 |
96 | width, height, bpp, unused_, tn_data = pdb.gimp_drawable_thumbnail(thumbnail_layer, width, height)
97 | spr_img.remove_layer(thumbnail_layer)
98 | return str(bytearray(tn_data)), width, height, bpp
99 |
100 | def get_thumbnail(thumbnail_data, texture_format):
101 | tn_data, width, height, bpp = thumbnail_data
102 |
103 | last_index = len(spr_img.colormap) // 3 - 1
104 |
105 | if texture_format != Sprite.TEXTURE_FORMAT_INDEXALPHA:
106 | tn_data = array('B', tn_data)
107 | if texture_format == Sprite.TEXTURE_FORMAT_ADDITIVE:
108 | for i in xrange(0, len(tn_data), 4):
109 | tn_data[i + 3] = sum(tn_data[i:i + 3]) // 3
110 | elif texture_format == Sprite.TEXTURE_FORMAT_ALPHATEST:
111 | for i in xrange(0, len(tn_data), 4):
112 | tn_data[i + 3] = 0xff - (tn_data[i + 3] // last_index * 0xff)
113 | else:
114 | for i in xrange(0, len(tn_data), 4):
115 | tn_data[i + 3] = 0xff
116 | tn_data = tn_data.tostring()
117 |
118 | return gtk.gdk.pixbuf_new_from_data(
119 | tn_data,
120 | gtk.gdk.COLORSPACE_RGB,
121 | True,
122 | 8,
123 | width, height,
124 | width * bpp)
125 |
126 | class ExportDialog(gimpui.Dialog):
127 |
128 | def __init__(self):
129 | gimpui.Dialog.__init__(self, title=ugettext('Export Image as Half-Life sprite'),
130 | role=EDITOR_PROC, help_id=None,
131 | buttons=(gtk.STOCK_CANCEL, gtk.RESPONSE_CLOSE,
132 | ugettext('Export'), RESPONSE_EXPORT))
133 |
134 | self.set_name(EDITOR_PROC)
135 | self.connect('response', self.on_response)
136 | self.connect('destroy', self.on_destroy)
137 |
138 | export_opt_box = self.make_export_options_box()
139 | self.img_view_frame = self.make_frames_view(reversed(spr_img.layers))
140 |
141 | hbox = gtk.HBox()
142 | hbox.pack_start(export_opt_box, True, True, 20)
143 | hbox.pack_start(self.img_view_frame, True, True, 5)
144 |
145 | self.vbox.pack_start(hbox)
146 | self.vbox.show_all()
147 |
148 | self.set_resizable(False)
149 | self.get_widget_for_response(RESPONSE_EXPORT).grab_focus()
150 |
151 | def update_thumbnails(self):
152 | texture_format = self.cb_tf.get_active()
153 | for ls in self.liststore:
154 | ls[LS_PIXBUF] = get_thumbnail(ls[LS_THUMBDATA], texture_format)
155 |
156 | def make_export_options_box(self):
157 | # Sprite type
158 | spr_type = spr_img.parasite_find('spr_type')
159 | self.cb_st = gtk.combo_box_new_text()
160 | self.cb_st.append_text('VP Parallel Upright')
161 | self.cb_st.append_text('Facing Upright')
162 | self.cb_st.append_text('VP Parallel')
163 | self.cb_st.append_text('Oriented')
164 | self.cb_st.append_text('VP Parallel Oriented')
165 | self.cb_st.set_tooltip_text(ugettext('Sprite Type'))
166 | self.cb_st.set_active(spr_type.flags if spr_type else 0)
167 |
168 | box = gtk.VBox(True, 5)
169 | box.pack_start(self.cb_st, False, False)
170 |
171 | st_frame = gimpui.Frame('Sprite Type:')
172 | st_frame.set_shadow_type(gtk.SHADOW_IN)
173 | st_frame.add(box)
174 |
175 | # Texture format
176 | texture_format = spr_img.parasite_find('spr_format')
177 | self.cb_tf = gtk.combo_box_new_text()
178 | self.cb_tf.append_text('Normal')
179 | self.cb_tf.append_text('Additive')
180 | self.cb_tf.append_text('Indexalpha')
181 | self.cb_tf.append_text('Alphatest')
182 | self.cb_tf.set_tooltip_text(ugettext('Texture Format'))
183 | self.cb_tf.set_active(texture_format.flags if texture_format else 0)
184 |
185 | def cb_tf_changed(cb):
186 | self.update_thumbnails()
187 |
188 | self.cb_tf.connect('changed', cb_tf_changed)
189 |
190 | box = gtk.VBox(True, 5)
191 | box.pack_start(self.cb_tf, False, False)
192 |
193 | tf_frame = gimpui.Frame('Texture Format:')
194 | tf_frame.set_shadow_type(gtk.SHADOW_IN)
195 | tf_frame.add(box)
196 |
197 | # Add origins offset
198 | lbl_oo_x = gtk.Label('Origin X:')
199 |
200 | adjustment = gtk.Adjustment(lower=MIN_FRAME_ORIGIN, upper=MAX_FRAME_ORIGIN, step_incr=1)
201 | self.sb_oo_x = gtk.SpinButton(adjustment=adjustment, climb_rate=1, digits=0)
202 | self.sb_oo_x.set_tooltip_text(ugettext('Offset for origin X'))
203 |
204 | box_origin_x = gtk.HBox(True, 12)
205 | box_origin_x.pack_start(lbl_oo_x, False, False)
206 | box_origin_x.pack_start(self.sb_oo_x, False, False)
207 |
208 | lbl_oo_y = gtk.Label('Origin Y:')
209 |
210 | adjustment = gtk.Adjustment(lower=MIN_FRAME_ORIGIN, upper=MAX_FRAME_ORIGIN, step_incr=1)
211 | self.sb_oo_y = gtk.SpinButton(adjustment=adjustment, climb_rate=1, digits=0)
212 | self.sb_oo_y.set_tooltip_text(ugettext('Offset for origin Y'))
213 |
214 | box_origin_y = gtk.HBox(True, 12)
215 | box_origin_y.pack_start(lbl_oo_y, False, False)
216 | box_origin_y.pack_start(self.sb_oo_y, False, False)
217 |
218 | def btn_oo_clicked(btn):
219 | offset_x = self.sb_oo_x.get_value_as_int()
220 | offset_y = self.sb_oo_y.get_value_as_int()
221 | for ls in self.liststore:
222 | ls[LS_ORIGIN_X] += offset_x
223 | ls[LS_ORIGIN_Y] += offset_y
224 |
225 | btn_oo = gtk.Button('Add offsets')
226 | btn_oo.connect('clicked', btn_oo_clicked)
227 |
228 | box = gtk.VBox(True, 5)
229 | box.pack_start(box_origin_x, False, False)
230 | box.pack_start(box_origin_y, False, False)
231 | box.pack_start(btn_oo, False, False)
232 |
233 | oo_frame = gimpui.Frame('Add origin offsets:')
234 | oo_frame.set_shadow_type(gtk.SHADOW_IN)
235 | oo_frame.add(box)
236 |
237 | # Main option frame
238 | o_box = gtk.VBox()
239 | o_box.set_size_request(110, -1)
240 | o_box.pack_start(st_frame, False, False, 10)
241 | o_box.pack_start(tf_frame, False, False, 10)
242 | o_box.pack_start(oo_frame, False, False, 10)
243 |
244 | box = gtk.VBox()
245 | box.set_size_request(140, -1)
246 | box.pack_start(o_box, True, False)
247 |
248 | return box
249 |
250 | def make_frames_view(self, layers):
251 | import gobject
252 |
253 | texture_format = self.cb_tf.get_active()
254 | self.liststore = gtk.ListStore(gobject.TYPE_PYOBJECT, gtk.gdk.Pixbuf, str, gobject.TYPE_BOOLEAN,
255 | gobject.TYPE_INT, gobject.TYPE_INT, gobject.TYPE_PYOBJECT)
256 | for l in layers:
257 | frames = [gl for gl in reversed(l.layers)] if pdb.gimp_item_is_group(l) else [l]
258 | for f in frames:
259 | thumbnail_data = make_thumbnail_data(f)
260 | pixbuf = get_thumbnail(thumbnail_data, texture_format)
261 | size_info = 'Size: %d x %d' % (f.width, f.height)
262 | parasite_origins = f.parasite_find('spr_origins')
263 | if parasite_origins:
264 | origin_x, origin_y = unpack('<2i', parasite_origins.data[:8])
265 | else:
266 | origin_x, origin_y = -f.width // 2, f.height // 2
267 | self.liststore.append([f, pixbuf, size_info, True, origin_x, origin_y, thumbnail_data])
268 |
269 | self.export_frames_num = len(self.liststore)
270 | self.iconview = gtk.TreeView(self.liststore)
271 | self.iconview.set_reorderable(True)
272 |
273 | self.iconview.set_enable_search(False)
274 | self.iconview.set_grid_lines(gtk.TREE_VIEW_GRID_LINES_BOTH)
275 |
276 | # Column 'Export'
277 | def on_cb_export_toggled(widget, path):
278 | export = not self.liststore[path][LS_EXPORT]
279 | self.liststore[path][LS_EXPORT] = export
280 | self.export_frames_num += 1 if export else -1
281 | self.set_btn_export_sensitive(self.export_frames_num > 0)
282 | self.img_view_frame.set_label('Frames to export: %d' % self.export_frames_num)
283 |
284 | cb_export = gtk.CellRendererToggle()
285 | cb_export.connect('toggled', on_cb_export_toggled)
286 |
287 | col_export = gtk.TreeViewColumn('Export', cb_export, active=LS_EXPORT)
288 | col_export_header = gtk.Label('Export')
289 | col_export_header.show()
290 |
291 | tt_export = gtk.Tooltips()
292 | tt_export.set_tip(col_export_header, 'Export frame to file.')
293 |
294 | col_export.set_sort_order(gtk.SORT_DESCENDING)
295 | col_export.set_sort_column_id(4)
296 |
297 | col_export.set_widget(col_export_header)
298 | self.iconview.append_column(col_export)
299 |
300 | # Column 'Frame'
301 | pixrend = gtk.CellRendererPixbuf()
302 | col_pixbuf = gtk.TreeViewColumn('Frame', pixrend, pixbuf=LS_PIXBUF)
303 | col_pixbuf.set_min_width(THUMB_MAXSIZE)
304 | self.iconview.append_column(col_pixbuf)
305 |
306 | # Column 'Settings'
307 | col_info = gtk.TreeViewColumn()
308 | col_info_header = gtk.Label('Settings')
309 | col_info_header.show()
310 | col_info.set_widget(col_info_header)
311 |
312 | tt_info = gtk.Tooltips()
313 | tt_info.set_tip(col_info_header, 'Frame export options.')
314 |
315 | # Info text
316 | renderer = gtk.CellRendererText()
317 | renderer.set_property('yalign', 0.3)
318 | renderer.set_property('xalign', 0.0)
319 | renderer.set_property('width', 0)
320 | renderer.set_property('height', THUMB_MAXSIZE)
321 | col_info.pack_start(renderer, False)
322 | col_info.set_attributes(renderer, markup=LS_SIZE_INFO)
323 |
324 | # Label origin X
325 | adjustment = gtk.Adjustment(lower=MIN_FRAME_ORIGIN, upper=MAX_FRAME_ORIGIN, step_incr=1)
326 | renderer = gtk.CellRendererText()
327 | renderer.set_property('markup', 'Origin X:')
328 | renderer.set_property('width', 64)
329 | col_info.pack_start(renderer, False)
330 |
331 | # crs origin x
332 | adjustment = gtk.Adjustment(lower=MIN_FRAME_ORIGIN, upper=MAX_FRAME_ORIGIN, step_incr=1)
333 | renderer = gtk.CellRendererSpin()
334 | renderer.set_property('editable', True)
335 | renderer.set_property('adjustment', adjustment)
336 |
337 | def on_crs_origin_x_changed(widget, path, val):
338 | val = min(max(int(val), MIN_FRAME_ORIGIN), MAX_FRAME_ORIGIN)
339 | self.liststore[path][LS_ORIGIN_X] = val
340 |
341 | renderer.connect('edited', on_crs_origin_x_changed)
342 |
343 | col_info.pack_start(renderer)
344 | col_info.set_attributes(renderer, markup=LS_ORIGIN_X)
345 |
346 | # Label origin y
347 | renderer = gtk.CellRendererText()
348 | renderer.set_property('markup', 'Origin Y:')
349 | renderer.set_property('width', 64)
350 | col_info.pack_start(renderer, False)
351 |
352 | # crs origin y
353 | renderer = gtk.CellRendererSpin()
354 | renderer.set_property('editable', True)
355 | renderer.set_property('adjustment', adjustment)
356 |
357 | def on_crs_origin_x_changed(widget, path, val):
358 | val = min(max(int(val), MIN_FRAME_ORIGIN), MAX_FRAME_ORIGIN)
359 | self.liststore[path][LS_ORIGIN_Y] = val
360 |
361 | renderer.connect('edited', on_crs_origin_x_changed)
362 |
363 | col_info.pack_start(renderer)
364 | col_info.set_attributes(renderer, markup=LS_ORIGIN_Y)
365 |
366 | self.iconview.append_column(col_info)
367 |
368 | scrl_win = gtk.ScrolledWindow()
369 | scrl_win.set_policy(gtk.POLICY_NEVER, gtk.POLICY_AUTOMATIC)
370 | scrl_win.add(self.iconview)
371 | scrl_win.set_size_request(THUMB_MAXSIZE, THUMB_MAXSIZE * 4)
372 |
373 | frame_imgs = gimpui.Frame('Frames to export: %d' % self.export_frames_num)
374 | frame_imgs.set_property('label-xalign', 0.05)
375 | frame_imgs.set_shadow_type(gtk.SHADOW_IN)
376 | frame_imgs.add(scrl_win)
377 | frame_imgs.set_size_request(535, -1)
378 |
379 | return frame_imgs
380 |
381 | def export_selected_frames(self):
382 | layers = []
383 | for row in self.liststore:
384 | if not row[LS_EXPORT]:
385 | continue
386 |
387 | layer = row[LS_LAYER]
388 | origins_data = pack('<2i', row[LS_ORIGIN_X], row[LS_ORIGIN_Y])
389 | if layer.parasite_find('spr_origins'):
390 | layer.parasite_detach('spr_origins')
391 | layer.attach_new_parasite('spr_origins', 0, origins_data)
392 |
393 | layers.append(layer)
394 |
395 | # Make grouped layers with parasites
396 | grouped_layers, added_layers = [], []
397 | for layer in layers:
398 | if layer in added_layers:
399 | continue
400 |
401 | added_layers.append(layer)
402 |
403 | parent = layer.parent
404 | if not parent:
405 | grouped_layers.append([layer])
406 | continue
407 |
408 | if not parent.parasite_find('spr_type'):
409 | parent.attach_new_parasite('spr_type', 1, '')
410 |
411 | group_lst = [ll for ll in layers if ll.parent == parent]
412 | for i, ll in enumerate(group_lst):
413 | interval = ll.parasite_find('spr_interval')
414 | if not interval:
415 | ll.attach_new_parasite('spr_interval', 0, pack('> 1) * (max_width >> 1) + (max_height >> 1) * (max_height >> 1))
102 |
103 | header = Sprite.SprHeader(Sprite.MAGIC, Sprite.VERSION_BMP, spr_type, texture_format,
104 | radius, max_width, max_height, frames_num, 0, 1)
105 |
106 | gimp.progress_init('Preparing %d %s' % (frames_num, 'frame' if frames_num == 1 else 'frames'))
107 | frames = []
108 | for i, gl in enumerate(grouped_layers):
109 | gl_len = len(gl)
110 | if gl_len > 1:
111 | frame_type = gl[0].parasite_find('spr_type').flags
112 | group_len = gl_len - 1
113 | intervals, params, indices = [], [], []
114 | for sub_l in gl[1:]:
115 | intervals.append(unpack(' 0:
280 | begin, end = dds_pos, data.find('DDS', dds_pos + 3)
281 | if end == -1:
282 | end, num = None, 0
283 | dds_bounds.append((begin, end))
284 | dds_pos = end
285 | num -= 1
286 | return [data[begin:end] for begin, end in dds_bounds]
287 |
288 | @staticmethod
289 | def _load_dds_frames(dds_frames):
290 | import tempfile
291 |
292 | images = []
293 | for i, data in enumerate(dds_frames):
294 | tempfd, temppath = tempfile.mkstemp(suffix='.dds')
295 |
296 | with open(temppath, 'wb') as fd:
297 | fd.write(data)
298 |
299 | exception = None
300 | try:
301 | images.append(pdb.file_dds_load(temppath, temppath, 0, 1))
302 | except RuntimeError as e:
303 | exception = e
304 |
305 | os.close(tempfd)
306 | os.unlink(temppath)
307 |
308 | if exception:
309 | fail('Error loading DDS frame_%d:\n\n%s!' % (i, e.message))
310 |
311 | return images
312 |
--------------------------------------------------------------------------------
/img/1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Psycrow101/GIMP-hl-sprite-plugin/62251ead1d9acd8f3c93e64e84fdc94cce4100cd/img/1.png
--------------------------------------------------------------------------------
/img/2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Psycrow101/GIMP-hl-sprite-plugin/62251ead1d9acd8f3c93e64e84fdc94cce4100cd/img/2.png
--------------------------------------------------------------------------------