using System; using System.IO; using System.IO.Compression; using System.Text; namespace LibMMD.Reader { /// /// Extracts MMD model archives and locates PMD or PMX model files. /// public static class MmdModelArchive { private static readonly int[] CandidateCodePages = { 65001, 932, 936, 950 }; /// /// Recreates , extracts the ZIP archive into it, /// and returns the first PMX or PMD file found below that directory. /// /// /// Entries that escape the destination directory and macOS AppleDouble files are ignored. /// A null return value means that extraction succeeded but the archive contained no model. /// /// A path argument is empty. /// The archive or destination cannot be read or written. /// The input is not a valid ZIP archive. public static string ExtractAndFindModel(string zipPath, string destinationDirectory) { if (string.IsNullOrWhiteSpace(zipPath)) throw new ArgumentException("A ZIP archive path is required.", nameof(zipPath)); if (string.IsNullOrWhiteSpace(destinationDirectory)) throw new ArgumentException("A destination directory is required.", nameof(destinationDirectory)); var destinationRoot = Path.GetFullPath(destinationDirectory); if (Directory.Exists(destinationRoot)) Directory.Delete(destinationRoot, true); Directory.CreateDirectory(destinationRoot); var rootPrefix = destinationRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; var entryEncoding = DetectEntryEncoding(zipPath); using (var stream = File.OpenRead(zipPath)) using (var archive = new ZipArchive(stream, ZipArchiveMode.Read, false, entryEncoding)) { foreach (var entry in archive.Entries) { var normalizedName = entry.FullName.Replace('/', Path.DirectorySeparatorChar); var trimmedName = normalizedName.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); var fileName = Path.GetFileName(trimmedName); if (fileName.StartsWith("._", StringComparison.Ordinal)) continue; var entryPath = Path.GetFullPath(Path.Combine(destinationRoot, normalizedName)); if (!entryPath.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase)) continue; if (string.IsNullOrEmpty(entry.Name)) { Directory.CreateDirectory(entryPath); continue; } var entryDirectory = Path.GetDirectoryName(entryPath); if (!string.IsNullOrEmpty(entryDirectory)) Directory.CreateDirectory(entryDirectory); entry.ExtractToFile(entryPath, true); } } return FindModelFile(destinationRoot); } /// Returns the first PMX or PMD file found recursively, or null when none exists. public static string FindModelFile(string directory) { if (string.IsNullOrWhiteSpace(directory)) throw new ArgumentException("A directory path is required.", nameof(directory)); foreach (var file in Directory.EnumerateFiles(directory)) { if (Path.GetFileName(file).StartsWith("._", StringComparison.Ordinal)) continue; var extension = Path.GetExtension(file); if (extension.Equals(".pmx", StringComparison.OrdinalIgnoreCase) || extension.Equals(".pmd", StringComparison.OrdinalIgnoreCase)) return file; } foreach (var childDirectory in Directory.EnumerateDirectories(directory)) { var modelPath = FindModelFile(childDirectory); if (modelPath != null) return modelPath; } return null; } private static Encoding DetectEntryEncoding(string zipPath) { var bestEncoding = Encoding.UTF8; var bestScore = int.MaxValue; foreach (var codePage in CandidateCodePages) { Encoding encoding; try { encoding = Encoding.GetEncoding(codePage); } catch (ArgumentException) { continue; } var score = ScoreEncoding(zipPath, encoding); if (score >= bestScore) continue; bestScore = score; bestEncoding = encoding; if (score == 0) break; } return bestEncoding; } private static int ScoreEncoding(string zipPath, Encoding encoding) { try { using (var stream = File.OpenRead(zipPath)) using (var archive = new ZipArchive(stream, ZipArchiveMode.Read, false, encoding)) { var replacements = 0; foreach (var entry in archive.Entries) foreach (var character in entry.FullName) { if (character == '\uFFFD' || character == '?') replacements++; } return replacements; } } catch (InvalidDataException) { return int.MaxValue; } catch (DecoderFallbackException) { return int.MaxValue; } } } }