# This file is part of Korman. # # Korman is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # Korman is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with Korman. If not, see . import enum from pathlib import Path from PyHSPlasma import * import time import weakref _HEADER_MAGICK = b"KTH\x00" _INDEX_MAGICK = b"KTI\x00" _DATA_MAGICK = b"KTC\x00" _ENTRY_MAGICK = b"KTE\x00" _IMAGE_MAGICK = b"KTT\x00" _MIP_MAGICK = b"KTM\x00" @enum.unique class _HeaderBits(enum.IntEnum): last_export = 0 index_pos = 1 @enum.unique class _IndexBits(enum.IntEnum): image_count = 0 @enum.unique class _EntryBits(enum.IntEnum): image_name = 0 mip_levels = 1 image_pos = 2 compression = 3 source_size = 4 export_size = 5 last_export = 6 image_count = 7 tag_string = 8 class _CachedImage: def __init__(self): self.name = None self.mip_levels = 1 self.data_pos = None self.image_data = None self.source_size = None self.export_size = None self.compression = None self.export_time = None self.modify_time = None self.image_count = 1 self.tag = None def __str__(self): return self.name class ImageCache: def __init__(self, exporter): self._exporter = weakref.ref(exporter) self._images = {} self._read_stream = hsFileStream() self._stream_handles = 0 def add_texture(self, texture, num_levels, export_size, compression, images): image, tag = texture.image, texture.tag image_name = str(texture) key = (image_name, tag, compression) ex_method, im_method = self._exporter().texcache_method, image.plasma_image.texcache_method method = set((ex_method, im_method)) if texture.ephemeral or "skip" in method: self._images.pop(key, None) return elif im_method == "rebuild": image.plasma_image.texcache_method = "use" image = _CachedImage() image.name = image_name image.mip_levels = num_levels image.compression = compression image.source_size = texture.image.size image.export_size = export_size image.image_data = images image.image_count = len(images) image.tag = tag self._images[key] = image def _compact(self): for key, image in self._images.copy().items(): if image.image_data is None: self._images.pop(key) def __enter__(self): if self._stream_handles == 0: path = self._exporter().texcache_path if Path(path).is_file(): self._read_stream.open(path, fmRead) self._stream_handles += 1 return self def __exit__(self, type, value, tb): self._stream_handles -= 1 if self._stream_handles == 0: self._read_stream.close() def get_from_texture(self, texture, compression): bl_image, tag = texture.image, texture.tag # If the texture is ephemeral (eg a lightmap) or has been marked "rebuild" or "skip" # in the UI, we don't want anything from the cache. In the first two cases, we never # want to cache that crap. In the latter case, we just want to signal a recache is needed. ex_method, im_method = self._exporter().texcache_method, texture.image.plasma_image.texcache_method method = set((ex_method, im_method)) if method != {"use"} or texture.ephemeral: return None key = (str(texture), tag, compression) cached_image = self._images.get(key) if cached_image is None: return None # ensure the texture key generally matches up with our copy of this image. # if not, a recache will likely be triggered implicitly. if tuple(bl_image.size) != cached_image.source_size: return None # if the image is on the disk, we can check the its modify time for changes if cached_image.modify_time is None: # if the image is packed, the filepath will be some garbage beginning with # the string "//". There isn't much we can do with that, unless the user # happens to have an unpacked copy lying around somewheres... path = Path(bl_image.filepath_from_user()) try: exists = path.is_file() except OSError: exists = False finally: if exists: cached_image.modify_time = path.stat().st_mtime if cached_image.export_time and cached_image.export_time < cached_image.modify_time: return None else: cached_image.modify_time = 0 # ensure the data has been loaded from the cache if cached_image.image_data is None: try: cached_image.image_data = tuple(self._read_image_data(cached_image, self._read_stream)) except AssertionError: self._report.warn(f"Cached copy of '{cached_image.name}' is corrupt and will be discarded") self._images.pop(key) return None return cached_image def load(self): if self._exporter().texcache_method == "skip": return try: with self: self._read(self._read_stream) except AssertionError: self._report.warn("Texture Cache is corrupt and will be regenerated") self._images.clear() def _read(self, stream): if stream.size == 0: return stream.seek(0) assert stream.read(4) == _HEADER_MAGICK # if we use a bit vector to define our header strcture, we can add # new fields without having to up the file version, trashing old # texture cache files... :) flags = hsBitVector() flags.read(stream) # ALWAYS ADD NEW FIELDS TO THE END OF THIS SECTION!!!!!!! if flags[_HeaderBits.last_export]: self.last_export = stream.readDouble() if flags[_HeaderBits.index_pos]: index_pos = stream.readInt() self._read_index(index_pos, stream) def _read_image_data(self, image, stream): if image.data_pos is None: return None assert stream.size > 0 stream.seek(image.data_pos) assert stream.read(4) == _IMAGE_MAGICK # unused currently image_flags = hsBitVector() image_flags.read(stream) # given this is a generator, someone else might change our stream position # between iterations, so we'd best bookkeep the position pos = stream.pos def _read_image_mips(): for _ in range(image.mip_levels): nonlocal pos if stream.pos != pos: stream.seek(pos) assert stream.read(4) == _MIP_MAGICK # this should only ever be image data... # store your flags somewhere else! size = stream.readInt() data = stream.read(size) pos = stream.pos yield data for _ in range(image.image_count): if stream.pos != pos: stream.seek(pos) yield tuple(_read_image_mips()) def _read_index(self, index_pos, stream): stream.seek(index_pos) assert stream.read(4) == _INDEX_MAGICK # See above, can change the index format easily... flags = hsBitVector() flags.read(stream) # ALWAYS ADD NEW FIELDS TO THE END OF THIS SECTION!!!!!!! image_count = stream.readInt() if flags[_IndexBits.image_count] else 0 # Here begins the image map assert stream.read(4) == _DATA_MAGICK for i in range(image_count): self._read_index_entry(stream) def _read_index_entry(self, stream): assert stream.read(4) == _ENTRY_MAGICK image = _CachedImage() # See above, can change the entry format easily... flags = hsBitVector() flags.read(stream) # ALWAYS ADD NEW FIELDS TO THE END OF THIS SECTION!!!!!!! if flags[_EntryBits.image_name]: image.name = stream.readSafeWStr() if flags[_EntryBits.mip_levels]: image.mip_levels = stream.readByte() if flags[_EntryBits.image_pos]: image.data_pos = stream.readInt() if flags[_EntryBits.compression]: image.compression = stream.readByte() if flags[_EntryBits.source_size]: image.source_size = (stream.readInt(), stream.readInt()) if flags[_EntryBits.export_size]: image.export_size = (stream.readInt(), stream.readInt()) if flags[_EntryBits.last_export]: image.export_time = stream.readDouble() if flags[_EntryBits.image_count]: image.image_count = stream.readInt() if flags[_EntryBits.tag_string]: # tags should not contain user data, so we will use a latin_1 backed string image.tag = stream.readSafeStr() # do we need to check for duplicate images? self._images[(image.name, image.tag, image.compression)] = image @property def _report(self): return self._exporter().report def save(self): if self._exporter().texcache_method == "skip": return # TODO: add a way to preserve unused images for a brief period so we don't toss # already cached images that are only removed from the age temporarily... self._compact() # Assume all read operations are done (don't be within' my cache while you savin') assert self._stream_handles == 0 with hsFileStream().open(self._exporter().texcache_path, fmWrite) as stream: self._write(stream) def _write(self, stream): flags = hsBitVector() flags[_HeaderBits.index_pos] = True stream.seek(0) stream.write(_HEADER_MAGICK) flags.write(stream) header_index_pos = stream.pos stream.writeInt(-1) for image in self._images.values(): self._write_image_data(image, stream) # fix the index position index_pos = stream.pos self._write_index(stream) stream.seek(header_index_pos) stream.writeInt(index_pos) def _write_image_data(self, image, stream): # unused currently flags = hsBitVector() image.data_pos = stream.pos stream.write(_IMAGE_MAGICK) flags.write(stream) for i in image.image_data: for j in i: stream.write(_MIP_MAGICK) stream.writeInt(len(j)) stream.write(j) def _write_index(self, stream): flags = hsBitVector() flags[_IndexBits.image_count] = True pos = stream.pos stream.write(_INDEX_MAGICK) flags.write(stream) stream.writeInt(len(self._images)) stream.write(_DATA_MAGICK) for image in self._images.values(): self._write_index_entry(image, stream) return pos def _write_index_entry(self, image, stream): flags = hsBitVector() flags[_EntryBits.image_name] = True flags[_EntryBits.mip_levels] = True flags[_EntryBits.image_pos] = True flags[_EntryBits.compression] = True flags[_EntryBits.source_size] = True flags[_EntryBits.export_size] = True flags[_EntryBits.last_export] = True flags[_EntryBits.image_count] = True flags[_EntryBits.tag_string] = image.tag is not None stream.write(_ENTRY_MAGICK) flags.write(stream) stream.writeSafeWStr(str(image)) stream.writeByte(image.mip_levels) stream.writeInt(image.data_pos) stream.writeByte(image.compression) stream.writeInt(image.source_size[0]) stream.writeInt(image.source_size[1]) stream.writeInt(image.export_size[0]) stream.writeInt(image.export_size[1]) stream.writeDouble(time.time()) stream.writeInt(image.image_count) if image.tag is not None: stream.writeSafeStr(image.tag)