using System.Collections.Generic; using System.IO; using System.Text; using UnityEngine; public partial class MeshPainter { // --- Persistence: named paint slots, saved/loaded explicitly from the HUD --- // // The painter no longer auto-loads on start or auto-saves on quit; painting is volatile until the // player explicitly saves it to a named slot, and a fresh session always starts from the material // seed. Saved slots live in a single global library folder (role/object independent), so any avatar // can load any saved painting as a camouflage preset. // Raw, bit-exact map dump: 'CHMP' magic + version + name + width + height + map count, then each // map's canonical Color32 bytes. Raw (not PNG) avoids any sRGB/alpha-premultiply ambiguity and // round-trips the exact canonical bytes ReadCanonical produces. private const uint PaintFileMagic = 0x504D4843; // 'CHMP' little-endian private const int PaintFileVersion = 4; // v3: emission + camo scalars; v4: + painted-height knobs private const string PaintFileExt = ".paint"; /// /// Per-player material scalars stored alongside the maps from v3 on. These are NOT baked into the /// painted pixels (a global emission multiplier + the PlayerCamoLit knobs), so they ride the slot /// header and are re-applied onto the painter when the slot loads. /// private struct PaintScalars { public bool has; // false = legacy v2 slot with no scalars -> leave the painter's values untouched public bool hasHeight; // v4+ painted-height knobs present (occlusion threshold + normal/parallax intensity) public float emissionIntensity; public float camoHalfLambert, camoHalfLambertBrightness, camoSpecularDampen; public float occlusionThreshold, heightNormalIntensity, parallaxIntensity; } private static string PaintLibraryDir => Path.Combine(Application.persistentDataPath, "MeshPaint", "Saved"); /// Filesystem-safe key: invalid filename characters and spaces become underscores. internal static string SanitizeFileKey(string s) { foreach (char c in Path.GetInvalidFileNameChars()) s = s.Replace(c, '_'); return s.Replace(' ', '_'); } private static string SanitizeKey(string s) => string.IsNullOrEmpty(s) ? "unnamed" : SanitizeFileKey(s); /// Disk path for a slot. The display name lives inside the file; the file name is a sanitized key. private static string PathForName(string name) => Path.Combine(PaintLibraryDir, SanitizeKey(name) + PaintFileExt); /// True if a slot with this name already exists (used to block overwriting on save). public static bool PaintExists(string name) => !string.IsNullOrWhiteSpace(name) && File.Exists(PathForName(name)); /// Display names of every saved slot, sorted alphabetically. Each name is read from the file header. public static List ListSavedPaintings() { var names = new List(); try { if (!Directory.Exists(PaintLibraryDir)) return names; foreach (string path in Directory.GetFiles(PaintLibraryDir, "*" + PaintFileExt)) { string n = ReadHeaderName(path); if (!string.IsNullOrEmpty(n)) names.Add(n); } } catch (System.Exception e) { Debug.LogWarning($"[MeshPainter] Listing saved paintings failed: {e.Message}"); } names.Sort(System.StringComparer.CurrentCultureIgnoreCase); return names; } /// /// Writes the current painted maps to a named slot. Returns false without writing if the painter is /// not ready. An existing slot of the same name is overwritten only when /// is true; otherwise the call fails rather than clobbering it. /// public bool SavePaintAs(string name, bool overwrite = false) { if (!_setupValid || string.IsNullOrWhiteSpace(name)) return false; name = name.Trim(); if (!overwrite && PaintExists(name)) return false; try { Directory.CreateDirectory(PaintLibraryDir); using (var fs = new FileStream(PathForName(name), FileMode.Create, FileAccess.Write)) using (var w = new BinaryWriter(fs)) { w.Write(PaintFileMagic); w.Write(PaintFileVersion); w.Write(name); // length-prefixed UTF8 (BinaryWriter.Write(string)) w.Write(textureSize); w.Write(textureSize); w.Write(MAP_COUNT); // v3: per-player material scalars that are NOT baked into the maps (a global emission // multiplier + the PlayerCamoLit knobs). The painter is ready here (_setupValid), so the // material-backed camo getters return the live values rather than the not-ready 0. w.Write(emissionIntensity); w.Write(CamoHalfLambert); w.Write(CamoHalfLambertBrightness); w.Write(CamoSpecularDampen); // v4: the painted-height knobs (same material-instance source of truth as the camo knobs). w.Write(OcclusionThreshold); w.Write(HeightNormalIntensity); w.Write(ParallaxIntensity); WriteCanonicalMaps(w); } return true; } catch (System.Exception e) { Debug.LogWarning($"[MeshPainter] Saving paint '{name}' failed: {e.Message}", this); return false; } } /// /// Loads a named slot and re-seeds the active backend with it, replacing the current painting. /// Returns false if the slot is missing or incompatible (the current painting is left untouched). /// public bool LoadPaintByName(string name) { if (!_setupValid) return false; Color32[][] canonical = ReadMaps(PathForName(name), out PaintScalars scalars); if (canonical == null) return false; ReseedCanonical(canonical); // A load replaces the whole painting and is NOT undoable: the loaded maps become the new history // baseline, clearing any prior undo/redo. Peers reset to the same baseline via the load broadcast. ResetHistoryToCurrent(canonical); // v3 slots also carry the per-player material scalars; apply them so the loaded look is complete. // Legacy v2 slots have none -> leave the current emission/camo untouched. The painter is ready here // (_setupValid), so the material-backed camo setters take effect; PlayerPaintSync replicates the // changed values to peers via their owner-authoritative NetworkVariables on its next LateUpdate. if (scalars.has) { emissionIntensity = scalars.emissionIntensity; RefreshEmissionColor(); CamoHalfLambert = scalars.camoHalfLambert; CamoHalfLambertBrightness = scalars.camoHalfLambertBrightness; CamoSpecularDampen = scalars.camoSpecularDampen; } // v4 slots also carry the painted-height knobs; older slots (v2/v3) leave them at the current value. if (scalars.hasHeight) { OcclusionThreshold = scalars.occlusionThreshold; HeightNormalIntensity = scalars.heightNormalIntensity; ParallaxIntensity = scalars.parallaxIntensity; } return true; } /// Deletes a named slot. Returns true if a file was removed. public static bool DeletePaint(string name) { try { string path = PathForName(name); if (!File.Exists(path)) return false; File.Delete(path); return true; } catch (System.Exception e) { Debug.LogWarning($"[MeshPainter] Deleting paint '{name}' failed: {e.Message}"); return false; } } /// Reads just the display-name header of a slot file (or the file name as a fallback). private static string ReadHeaderName(string path) { try { using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read)) using (var r = new BinaryReader(fs, Encoding.UTF8)) { if (!TryReadPaintVersion(r, out _)) return null; return r.ReadString(); } } catch { return Path.GetFileNameWithoutExtension(path); } } /// /// Reads all maps from a slot file in canonical layout, or null if missing / incompatible. Per-map /// null entries are tolerated by both backends (they fall back to the material seed). /// private Color32[][] ReadMaps(string path, out PaintScalars scalars) { scalars = default; if (!File.Exists(path)) return null; try { using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read)) using (var r = new BinaryReader(fs, Encoding.UTF8)) { if (!TryReadPaintVersion(r, out int ver)) return null; r.ReadString(); // display name (header only; not needed for the pixel load) int w = r.ReadInt32(); int h = r.ReadInt32(); int maps = r.ReadInt32(); // v3+: the per-player material scalars sit between the header and the pixel maps. if (ver >= 3) { scalars.has = true; scalars.emissionIntensity = r.ReadSingle(); scalars.camoHalfLambert = r.ReadSingle(); scalars.camoHalfLambertBrightness = r.ReadSingle(); scalars.camoSpecularDampen = r.ReadSingle(); } // v4+: the painted-height knobs follow the camo scalars. if (ver >= 4) { scalars.hasHeight = true; scalars.occlusionThreshold = r.ReadSingle(); scalars.heightNormalIntensity = r.ReadSingle(); scalars.parallaxIntensity = r.ReadSingle(); } // Resolution change -> ignore rather than smear mismatched data; the brush re-seeds // from the material instead. if (w != textureSize || h != textureSize) { Debug.LogWarning($"[MeshPainter] Saved paint ignored (got {w}x{h}, expected " + $"{textureSize}x{textureSize}).", this); return null; } return ReadCanonicalMaps(r, maps); } } catch (System.Exception e) { Debug.LogWarning($"[MeshPainter] Loading paint failed: {e.Message}", this); return null; } } /// Validates the paint-file magic and version range (legacy v2 .. current). private static bool TryReadPaintVersion(BinaryReader r, out int ver) { if (r.ReadUInt32() != PaintFileMagic) { ver = 0; return false; } ver = r.ReadInt32(); return ver >= 2 && ver <= PaintFileVersion; } /// Writes every map's canonical Color32 bytes to the stream (shared by slot save + net snapshot). private void WriteCanonicalMaps(BinaryWriter w) { int n = textureSize * textureSize; var bytes = new byte[n * 4]; for (int mi = 0; mi < MAP_COUNT; mi++) { Color32[] px = ReadCanonical(mi); int count = Mathf.Min(n, px.Length); for (int i = 0; i < count; i++) { int o = i * 4; bytes[o] = px[i].r; bytes[o + 1] = px[i].g; bytes[o + 2] = px[i].b; bytes[o + 3] = px[i].a; } w.Write(bytes, 0, count * 4); } } /// Reads up to MAP_COUNT canonical map blocks; a truncated stream leaves the remaining maps null (seeded). private Color32[][] ReadCanonicalMaps(BinaryReader r, int mapsInFile) { int n = textureSize * textureSize; var result = new Color32[MAP_COUNT][]; int load = Mathf.Min(mapsInFile, MAP_COUNT); for (int mi = 0; mi < load; mi++) { byte[] raw = r.ReadBytes(n * 4); if (raw.Length != n * 4) break; // truncated -> remaining maps stay at the material seed var px = new Color32[n]; for (int i = 0; i < n; i++) { int o = i * 4; px[i] = new Color32(raw[o], raw[o + 1], raw[o + 2], raw[o + 3]); } result[mi] = px; } return result; } }