From ecbc3978f1ea1401eee5bcbab652aa72702b351b Mon Sep 17 00:00:00 2001 From: Brychan Dempsey Date: Sat, 16 Oct 2021 16:21:46 +1300 Subject: [PATCH] Moved functionality into the application root Added a CachedByteArray class that will store compressed (disk-ready) images on the appropriate media --- PDGServer_WPF/App.xaml.cs | 194 +++++++++++++++++++ RBG_Server.Core/CachedByteArray.cs | 240 ++++++++++++++++++++++++ RBG_Server.Core/CommunicationHandler.cs | 24 +++ 3 files changed, 458 insertions(+) create mode 100644 RBG_Server.Core/CachedByteArray.cs create mode 100644 RBG_Server.Core/CommunicationHandler.cs diff --git a/PDGServer_WPF/App.xaml.cs b/PDGServer_WPF/App.xaml.cs index c866abb..1c8b5ca 100644 --- a/PDGServer_WPF/App.xaml.cs +++ b/PDGServer_WPF/App.xaml.cs @@ -1,10 +1,15 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Configuration; using System.Data; +using System.IO; using System.Linq; +using System.Text; using System.Threading.Tasks; using System.Windows; +using System.Windows.Media; +using System.Windows.Media.Imaging; namespace RBG_Server { @@ -13,5 +18,194 @@ namespace RBG_Server /// public partial class App : Application { + public GameServer BoardGameServer {get;} + public CommunicationHandler communicationHandler { get; private set;} + protected static readonly SolidColorBrush RED_BRUSH = new(Color.FromRgb(255, 0, 0)); + protected static readonly SolidColorBrush BLUE_BRUSH = new(Color.FromRgb(0, 0, 255)); + protected static readonly SolidColorBrush GREEN_BRUSH = new(Color.FromRgb(0, 255, 0)); + + /// + /// Loads the specified files from disk, creating mip maps as appropriate + /// + /// + /// + /// + void LoadFiles(string[] selectedFiles, bool isSprite=false, int spriteLimit=128) + { + // Continue in parallel + _ = Parallel.ForEach(selectedFiles, (file) => + { + LoadFile(file); + }); + } + /// + /// Loads a singular file from disk, generating Mip maps as required + /// + /// + void LoadFile(string path, bool isSprite=true) + { + // Check if the file is a .gif, first. + using FileStream fs = File.OpenRead(path); + LoadStream(fs, path, isSprite); + + } + + void LoadStream(Stream source, string path, bool isSprite) + { + long srcStart = source.Position; + byte[] vs = new byte[6]; // First 6 bytes is the gif specifier + source.Read(vs, 0, 6); + source.Position = srcStart; + string magicNumber = Encoding.ASCII.GetString(vs); + if (magicNumber == "GIF87a" || magicNumber == "GIF89a") + { + // Gif file, replace with a layered .png + GifBitmapDecoder decoder = new(source, BitmapCreateOptions.None, BitmapCacheOption.Default); + // Obtain each .gif frame + BitmapFrame[] frames = new BitmapFrame[decoder.Frames.Count]; + decoder.Frames.CopyTo(frames, 0); + // Get frame sizes + int height = frames[0].PixelHeight; + int width = frames[0].PixelWidth; + // The lowest mip is static, using either the thumb or first frame + if (decoder.Thumbnail != null) + { + double scaleRate = 32 / (double)Math.Max(height, width); + MemoryStream ms = ScaleImage(decoder.Thumbnail, scaleRate); + _ = communicationHandler.ImageCollection.TryAdd(Path.GetFileNameWithoutExtension(path) + "_mip_low" + Path.GetExtension(path), new CachedByteArray(ms.ToArray())); + } + else + { + double scaleRate = 32 / (double)Math.Max(height, width); + MemoryStream ms = ScaleImage(frames[0], scaleRate); + _ = communicationHandler.ImageCollection.TryAdd(Path.GetFileNameWithoutExtension(path) + "_mip_low" + Path.GetExtension(path), new CachedByteArray(ms.ToArray())); + } + // The remaining mips are animated + if (width >= 128 || height >= 128) + { + double scaleRate = 128 / (double)Math.Max(height, width); + MemoryStream ms = ScaleAnimatedImage(frames, scaleRate); + _ = communicationHandler.ImageCollection.TryAdd(Path.GetFileNameWithoutExtension(path) + "_mip_med" + Path.GetExtension(path), new CachedByteArray(ms.ToArray())); + } + if (width >= 512 || height >= 512) + { + double scaleRate = 512 / (double)Math.Max(height, width); + MemoryStream ms = ScaleAnimatedImage(frames, scaleRate); + _ = communicationHandler.ImageCollection.TryAdd(Path.GetFileNameWithoutExtension(path) + "_mip_high" + Path.GetExtension(path), new CachedByteArray(ms.ToArray())); + } + if (width >= 512 || height >= 512) + { + // Just re-encode the gif to .png + PngBitmapEncoder encoder = new(); + foreach (BitmapFrame frame in frames) + { + encoder.Frames.Add(frame); + } + MemoryStream ms = new(); + encoder.Save(ms); + _ = communicationHandler.ImageCollection.TryAdd(Path.GetFileNameWithoutExtension(path) + "_mip_raw" + Path.GetExtension(path), new CachedByteArray(ms.ToArray())); + } + + } + else + { + // Regular image file + // Create the bitmap to get the current intrinsics + BitmapImage bitmapImage = new(); + bitmapImage.BeginInit(); + bitmapImage.StreamSource = source; + bitmapImage.CacheOption = BitmapCacheOption.None; + bitmapImage.EndInit(); + int height = bitmapImage.PixelHeight; + int width = bitmapImage.PixelWidth; + + // Behaviour depends on the required image + if (isSprite) + { + // Sprites should have a low mip-map, and the medium + // Low = 32*32 + // Medium = 128*128 + // High = 512*512 or image size if less than 1024 + if (width >= 32 || height >= 32) + { + double scaleRate = 32 / (double)Math.Max(height, width); + MemoryStream ms = ScaleImage(bitmapImage, scaleRate); + _ = communicationHandler.ImageCollection.TryAdd(Path.GetFileNameWithoutExtension(path) + "_mip_low" + Path.GetExtension(path), new CachedByteArray(ms.ToArray())); + } + if (width >= 128 || height >= 128) + { + double scaleRate = 128 / (double)Math.Max(height, width); + MemoryStream ms = ScaleImage(bitmapImage, scaleRate); + _ = communicationHandler.ImageCollection.TryAdd(Path.GetFileNameWithoutExtension(path) + "_mip_med" + Path.GetExtension(path), new CachedByteArray(ms.ToArray())); + } + if (width >= 512 || height >= 512) + { + double scaleRate = 512 / (double)Math.Max(height, width); + MemoryStream ms = ScaleImage(bitmapImage, scaleRate); + _ = communicationHandler.ImageCollection.TryAdd(Path.GetFileNameWithoutExtension(path) + "_mip_high" + Path.GetExtension(path), new CachedByteArray(ms.ToArray())); + } + if (width >= 512 || height >= 512) + { + MemoryStream ms = new(); + source.CopyTo(ms); + _ = communicationHandler.ImageCollection.TryAdd(Path.GetFileNameWithoutExtension(path) + "_mip_raw" + Path.GetExtension(path), new CachedByteArray(ms.ToArray())); + } + } + else + { + // Non-sprites should have a low mip-map, and the medium, but at a larger size than the sprites + // Low = 128*128 + // Medium = 512*512 + // High = 2048*2048 + if (width >= 128 || height >= 128) + { + double scaleRate = 128 / (double)Math.Max(height, width); + MemoryStream ms = ScaleImage(bitmapImage, scaleRate); + _ = communicationHandler.ImageCollection.TryAdd(Path.GetFileNameWithoutExtension(path) + "_mip_low" + Path.GetExtension(path), new CachedByteArray(ms.ToArray())); + } + if (width >= 512 || height >= 512) + { + double scaleRate = 512 / (double)Math.Max(height, width); + MemoryStream ms = ScaleImage(bitmapImage, scaleRate); + _ = communicationHandler.ImageCollection.TryAdd(Path.GetFileNameWithoutExtension(path) + "_mip_med" + Path.GetExtension(path), new CachedByteArray(ms.ToArray())); + } + if (width >= 2048 || height >= 2048) + { + double scaleRate = 2048 / (double)Math.Max(height, width); + MemoryStream ms = ScaleImage(bitmapImage, scaleRate); + _ = communicationHandler.ImageCollection.TryAdd(Path.GetFileNameWithoutExtension(path) + "_mip_high" + Path.GetExtension(path), new CachedByteArray(ms.ToArray())); + } + if (width >= 2048 || height >= 2048) + { + MemoryStream ms = new(); + source.CopyTo(ms); + _ = communicationHandler.ImageCollection.TryAdd(Path.GetFileNameWithoutExtension(path) + "_mip_raw" + Path.GetExtension(path), new CachedByteArray(ms.ToArray())); + } + } + } + } + + MemoryStream ScaleImage(BitmapSource source, double scaleRate) + { + TransformedBitmap modified = new(source, new ScaleTransform(source.PixelWidth * scaleRate, source.PixelHeight * scaleRate)); + PngBitmapEncoder encoder = new(); + encoder.Frames.Add(BitmapFrame.Create(modified)); + MemoryStream ms = new(); + encoder.Save(ms); + return ms; + } + + MemoryStream ScaleAnimatedImage(BitmapFrame[] frames, double scaleRate) + { + PngBitmapEncoder encoder = new(); + foreach (BitmapFrame frame in frames) + { + TransformedBitmap modified = new(frame, new ScaleTransform(frame.PixelWidth * scaleRate, frame.PixelHeight * scaleRate)); + encoder.Frames.Add(BitmapFrame.Create(modified)); + } + MemoryStream ms = new(); + encoder.Save(ms); + return ms; + } } } diff --git a/RBG_Server.Core/CachedByteArray.cs b/RBG_Server.Core/CachedByteArray.cs new file mode 100644 index 0000000..ca9090d --- /dev/null +++ b/RBG_Server.Core/CachedByteArray.cs @@ -0,0 +1,240 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace RBG_Server +{ + /// + /// Stores data in either memory or disk, depending on the required behaviour of the system, namely, if the + /// byte array exceeds the size of the Memory Limit (objects can be required to stay in RAM) + /// + public struct CachedByteArray : IDisposable + { + // Static event, so that every instance of CachedByteArray is affected by a change in memory limit + private static event EventHandler MemoryLimitModifiedEvent; + // Properties to keep track of the actual allocated cache amounts + private static long memoryAllocationTotal = 0; + private static long memoryAllocationRAM = 0; + // static fields + private static int memoryLimit = 1024 * 1024 * 1; // Start with a memory limit of 1 MB + + private static readonly string folderPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Remote Board Game\\Cache Objects"); + // static properties + public static long MemoryAllocationRAM { get { return memoryAllocationRAM; } } + public static long MemoryAllocationTotal { get { return memoryAllocationTotal; } } + public static long MemoryAllocationDisk { get { return memoryAllocationTotal - memoryAllocationRAM; } } + + private SemaphoreSlim accessSem; + + public bool IsInRam { get; private set; } + + // struct data + private bool useOnlyRAM; + + private Stream dataStream; + + private string dataHashCode; + public bool UseOnlyRAM { get => useOnlyRAM; set => useOnlyRAM = value; } + // Properties from the array itself + public long Length => dataStream.Length; + /// + /// Set the memory limit for keeping data in RAM, as opposed to storing on disk. + /// Will raise events if modified, so all instances will be refactored to disk if appropriate + /// + public static int MemoryLimit + { + get + { + return memoryLimit; + } + set + { + memoryLimit = value; + MemoryLimitModifiedEvent?.Invoke(null, EventArgs.Empty); + } + } + + public CachedByteArray(byte[] b) : this(b, false) + { + } + + public CachedByteArray(byte[] b, bool useOnlyRAM) + { + accessSem = new SemaphoreSlim(0,1); + this.useOnlyRAM = useOnlyRAM; + dataHashCode = GenerateDataHash(b); + if (!useOnlyRAM && b.Length > MemoryLimit) + { + // Save to a file; load the data + string path = Path.Combine(folderPath, dataHashCode); + dataStream = CopyIntoFile(b, path); + IsInRam = false; + } + else + { + dataStream = new MemoryStream(b); + memoryAllocationRAM += dataStream.Length; + IsInRam = true; + } + dataStream.Position = 0; + memoryAllocationTotal += dataStream.Length; + // Signal the semaphore is ready + accessSem.Release(); + // Listen to changes in the memory limit + MemoryLimitModifiedEvent += CachedByteArray_memoryLimitModifiedEvent; + } + /// + /// Generates a SHA256 hash of the data in the array, and returns it in a safe string format + /// + /// + /// + private static string GenerateDataHash(byte[] byteData) + { + using SHA256 dataHash = SHA256.Create(); + byte[] hashCode = dataHash.ComputeHash(byteData); + string generatedHashCode = ""; + foreach (byte hashByte in hashCode) + { + generatedHashCode += $"{hashByte:X2}"; + } + return generatedHashCode; + } + /// + /// Copies the given into the file at , opening the stream, and returning it + /// + /// + /// + /// + private static Stream CopyIntoFile(byte[] byteData, string filePath) + { + FileStream s = File.OpenWrite(filePath); + foreach (byte b in byteData) + { + s.WriteByte(b); + } + return s; + } + + private void CachedByteArray_memoryLimitModifiedEvent(object sender, EventArgs e) + { + if (!useOnlyRAM) + { + if (dataStream.Length > MemoryLimit && IsInRam) + { + // Memory stream exceeds the limit; create an FS and copy + accessSem.Wait(); + // Get the underlying byte[] + byte[] dataBytes = ((MemoryStream)dataStream).ToArray(); + // Generate the hashcode from the data + dataHashCode = GenerateDataHash(dataBytes); + // Create the file, and copy the data + dataStream = CopyIntoFile(dataBytes, Path.Combine(folderPath, dataHashCode)); + // Disregard the allocation from RAM + memoryAllocationRAM -= dataStream.Length; + accessSem.Release(); + } + else if (dataStream.Length <= MemoryLimit && !IsInRam) + { + // Data stream is no larger than the limit, but the file is saved on disk; load to RAM instead + // Obtain a lock on the stream + accessSem.Wait(); + // Copy data to memory + dataStream.Position = 0; + MemoryStream ms = new(); + dataStream.CopyTo(ms); + // Increase the amount noted in RAM + memoryAllocationRAM += ms.Length; + // Close the FileStream + dataStream.Close(); + // Delete the file + DeleteFile(Path.Combine(folderPath, dataHashCode)); + // Set the datastream to the memory location + dataStream = ms; + accessSem.Release(); + // Check the loaded data matches the hashcode we already have + string loadedHashCode = GenerateDataHash(ms.ToArray()); + if (loadedHashCode != dataHashCode) + { + throw new UnauthorizedAccessException("Data was modified"); + } + } + // Otherwise, the data is already in the correct spot, so we don't need to do anything else + } + } + private static void DeleteFile(string filePath) + { + try + { + File.Delete(filePath); + } + catch (Exception ex) + { + Debug.WriteLine(ex); + } + } + public static implicit operator byte[](CachedByteArray c) + { + byte[] dataBytes = new byte[c.dataStream.Length]; + c.accessSem.Wait(); + if (c.IsInRam) + { + // We can use the underlying array from the MemoryStream + dataBytes = (c.dataStream as MemoryStream).ToArray(); + } + else + { + // Ensure we read from the beginning of the stream + c.dataStream.Position = 0; + c.dataStream.Read(dataBytes, 0, dataBytes.Length); + } + c.accessSem.Release(); + return dataBytes; + } + + public static explicit operator CachedByteArray(byte[] b) => new CachedByteArray(b); + /// + /// Array index operator, allows the CachedByteArray to act more completely as a byte[] + /// + /// + /// + public byte this[int index] + { + get + { + int b; + // Aquire the semaphore, so that the read is effectively atomic (ensures the read is performed on the expected data) + accessSem.Wait(); + dataStream.Position = index; + b = dataStream.ReadByte(); + accessSem.Release(); + if (b == -1) + { + throw new IndexOutOfRangeException(index.ToString()); + } + return (byte)b; + } + } + + public void Dispose() + { + accessSem.Wait(); + // Remove the event listener + MemoryLimitModifiedEvent -= CachedByteArray_memoryLimitModifiedEvent; + // Remove the allocation from the counter + memoryAllocationTotal -= dataStream.Length; + memoryAllocationRAM -= dataStream.Length; + // If the FileStream is opened, close and dispose it + dataStream?.Dispose(); + // If the file is on disk, remove it + DeleteFile(Path.Combine(folderPath, dataHashCode)); + // Release all resources obtained by the semaphore + accessSem.Dispose(); + } + } +} diff --git a/RBG_Server.Core/CommunicationHandler.cs b/RBG_Server.Core/CommunicationHandler.cs new file mode 100644 index 0000000..92fd9b8 --- /dev/null +++ b/RBG_Server.Core/CommunicationHandler.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace RBG_Server +{ + /// + /// Contains all communication data, from both the client's and server's perspective (should be able to switch between each mode as necessary) + /// + public class CommunicationHandler + { + + /// + /// Image data is stored in memory in a dictionary collection. Each byte[] represents an image file, compressed according to its file type; + /// which helps save space in memory. Uses a CachedByteArray; essentially a normal byte array but automatically stored on disk if it is larger + /// than a set size. + /// This limit can be changed at any time + /// + public ConcurrentDictionary ImageCollection { get; } = new(); + } +}