├── 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 --------------------------------------------------------------------------------