diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/datadriven/DataDrivenGuideLoader.java b/src/main/java/com/hfstudio/guidenh/guide/internal/datadriven/DataDrivenGuideLoader.java index 0871805f..ee6936df 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/datadriven/DataDrivenGuideLoader.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/datadriven/DataDrivenGuideLoader.java @@ -89,33 +89,13 @@ private record NamespaceRoot(String namespace, File directory, boolean allowDire private static volatile @Nullable ScanCache lastScanCache = null; - private record ScanCache(List packRoots, String folder, - List contentManifest, ScanResult result, + private record ScanCache(String folder, List contentManifest, ScanResult result, Map> pagePackIndexSnapshot, Map> assetPackIndexSnapshot, Map> langFilePathsSnapshot) { - boolean matchesPackMetadata(List roots, String f) { - if (!folder.equals(f) || !packRoots.equals(roots) || roots.size() != contentManifest.size()) { - return false; - } - for (int i = 0; i < roots.size(); i++) { - File current = roots.get(i); - File previous = packRoots.get(i); - // ZIP contents can only change when the archive's size or timestamp changes. - // Directory packs are deliberately rescanned so file-level hot reloads remain - // visible even when the directory timestamp is unchanged on some file systems. - if (current.isDirectory() || previous.isDirectory() - || current.length() != previous.length() - || current.lastModified() != previous.lastModified()) { - return false; - } - } - return true; - } - - boolean matches(List roots, String f, List manifest) { - return folder.equals(f) && packRoots.equals(roots) && contentManifest.equals(manifest); + boolean matches(String f, List manifest) { + return folder.equals(f) && contentManifest.equals(manifest); } } @@ -132,9 +112,12 @@ public static ScanResult scanAndBuildAll(String folder, Iterable capture(List roots, String guideFolder) { + return capture(roots, guideFolder, List.of()); + } + + /** + * Reuses the immutable ZIP metadata from the last scan. Directory packs remain content-aware + * and are rescanned on every reload because changing a child does not reliably update the + * directory timestamp across filesystems. + */ + public static List capture(List roots, String guideFolder, List previous) { var result = new ArrayList(roots.size()); - for (File root : roots) { - result.add(root.isDirectory() ? captureDirectory(root, guideFolder) : captureZip(root)); + for (int index = 0; index < roots.size(); index++) { + File root = roots.get(index); + Pack cached = index < previous.size() ? previous.get(index) : null; + if (!root.isDirectory() && cached != null && cached.matchesZip(root)) { + result.add(cached); + } else { + result.add(root.isDirectory() ? captureDirectory(root, guideFolder) : captureZip(root)); + } } return List.copyOf(result); } @@ -41,47 +61,61 @@ public static Pack captureDirectory(File root, String guideFolder) { .normalize(); var hashes = new LongHashBuffer(); - collectFiles(absoluteRoot.resolve("assets"), absoluteRoot, hashes); + Path assets = absoluteRoot.resolve("assets"); + collectAssets(assets, absoluteRoot, guideFolder, hashes); try (var children = Files.list(absoluteRoot)) { children.filter(Files::isDirectory) .filter( path -> !"assets".equals( path.getFileName() .toString())) - .forEach(path -> collectFiles(path.resolve(guideFolder), absoluteRoot, hashes)); + .forEach(path -> collectNativeNamespace(path, absoluteRoot, guideFolder, hashes)); } catch (IOException e) { hashes.add(entryHash("", -1L, -1L, -1L)); } - return new Pack(root, true, hashes.size(), hashes.fingerprint()); + return new Pack( + normalizePath(root), + true, + root.length(), + root.lastModified(), + hashes.size(), + hashes.fingerprint()); } - public static void collectFiles(Path directory, Path root, LongHashBuffer hashes) { + private static void collectAssets(Path assets, Path root, String guideFolder, LongHashBuffer hashes) { + if (!Files.isDirectory(assets)) return; + try (var namespaces = Files.list(assets)) { + namespaces.filter(Files::isDirectory) + .forEach(namespace -> { + collectFiles(namespace.resolve(guideFolder), root, hashes); + collectLangFiles(namespace.resolve("lang"), root, hashes); + }); + } catch (IOException e) { + hashes.add(entryHash("assets/", -1L, -1L, -1L)); + } + } + + private static void collectNativeNamespace(Path namespace, Path root, String guideFolder, LongHashBuffer hashes) { + collectFiles(namespace.resolve(guideFolder), root, hashes); + if (guideFolder.equals( + namespace.getFileName() + .toString())) { + collectFiles(namespace, root, hashes); + } + collectLangFiles(namespace.resolve("lang"), root, hashes); + } + + private static void collectLangFiles(Path directory, Path root, LongHashBuffer hashes) { if (!Files.isDirectory(directory)) return; byte[] contentBuffer = new byte[16 * 1024]; try (var files = Files.walk(directory)) { files.filter(Files::isRegularFile) - .forEach(path -> { - try { - var attributes = Files.readAttributes(path, BasicFileAttributes.class); - String relativePath = root.relativize(path) - .toString() - .replace(File.separatorChar, '/'); - long contentHash = shouldHashContent(relativePath) ? contentChecksum(path, contentBuffer) : -1L; - hashes.add( - entryHash( - relativePath, - attributes.size(), - attributes.lastModifiedTime() - .toMillis(), - contentHash)); - } catch (IOException e) { - String relativePath = root.relativize(path) - .toString() - .replace(File.separatorChar, '/'); - hashes.add(entryHash(relativePath, -1L, -1L, -1L)); - } - }); + .filter( + path -> path.getFileName() + .toString() + .endsWith(".lang")) + .forEach(path -> addFile(path, root, hashes, contentBuffer)); } catch (IOException e) { hashes.add( entryHash( @@ -94,21 +128,26 @@ public static void collectFiles(Path directory, Path root, LongHashBuffer hashes } } - public static Pack captureZip(File root) { - var hashes = new LongHashBuffer(); - try (var zip = new ZipFile(root)) { - var zipEntries = zip.entries(); - while (zipEntries.hasMoreElements()) { - var entry = zipEntries.nextElement(); - if (!entry.isDirectory() && entry.getName() - .startsWith("assets/")) { - hashes.add(entryHash(entry.getName(), entry.getSize(), entry.getTime(), entry.getCrc())); - } - } + public static void collectFiles(Path directory, Path root, LongHashBuffer hashes) { + if (!Files.isDirectory(directory)) return; + byte[] contentBuffer = new byte[16 * 1024]; + try (var files = Files.walk(directory)) { + files.filter(Files::isRegularFile) + .forEach(path -> addFile(path, root, hashes, contentBuffer)); } catch (IOException e) { - hashes.add(entryHash("", -1L, -1L, -1L)); + hashes.add( + entryHash( + root.relativize(directory) + .toString() + .replace(File.separatorChar, '/') + "/", + -1L, + -1L, + -1L)); } - return new Pack(root, false, hashes.size(), hashes.fingerprint()); + } + + public static Pack captureZip(File root) { + return new Pack(normalizePath(root), false, root.length(), root.lastModified(), 0, 0L); } public static long entryHash(String path, long size, long modified, long crc) { @@ -147,23 +186,29 @@ private static long contentChecksum(Path path, byte[] buffer) { } } - private static boolean isGuideRelevantPath(String path) { - return path.endsWith(".lang") || path.contains("/guidenh/") || path.endsWith("/guidenh"); + private static void addFile(Path path, Path root, LongHashBuffer hashes, byte[] contentBuffer) { + String relativePath = root.relativize(path) + .toString() + .replace(File.separatorChar, '/'); + try { + var attributes = Files.readAttributes(path, BasicFileAttributes.class); + hashes.add( + entryHash( + relativePath, + attributes.size(), + attributes.lastModifiedTime() + .toMillis(), + contentChecksum(path, contentBuffer))); + } catch (IOException e) { + hashes.add(entryHash(relativePath, -1L, -1L, -1L)); + } } - private static boolean shouldHashContent(String path) { - if (isGuideRelevantPath(path)) { - return true; - } - String lower = path.toLowerCase(Locale.ROOT); - return lower.endsWith(".png") || lower.endsWith(".jpg") - || lower.endsWith(".jpeg") - || lower.endsWith(".gif") - || lower.endsWith(".webp") - || lower.endsWith(".mcmeta") - || lower.endsWith(".json") - || lower.endsWith(".snbt") - || lower.endsWith(".nbt"); + private static String normalizePath(File root) { + return root.toPath() + .toAbsolutePath() + .normalize() + .toString(); } /** Small primitive buffer used only during a scan; no per-entry objects survive the scan. */