diff --git a/src/main/java/com/dostalgia/GameService.java b/src/main/java/com/dostalgia/GameService.java index ca483bf..7e77cbb 100644 --- a/src/main/java/com/dostalgia/GameService.java +++ b/src/main/java/com/dostalgia/GameService.java @@ -205,20 +205,10 @@ public class GameService { } /** - * Known game config files with hardcoded DOS paths that need fixing. - * Same as UploadResource.CONFIG_PATH_FIXES but duplicated here for - * the fix-paths endpoint (no dependency on UploadResource). + * Patch hardcoded absolute DOS paths (C:\dir\ → .\) in game config files + * within a .jsdos bundle. Game-agnostic — scans all small text entries + * for drive-letter paths and rewrites stale directory prefixes. */ - private static final java.util.Map> CONFIG_PATH_FIXES = - java.util.Map.of( - "fallout.cfg", java.util.List.of( - "c:\\fallout1\\", "C:\\fallout1\\", - "c:\\fallout2\\", "C:\\fallout2\\", - "c:\\fallout\\", "C:\\fallout\\" - ) - ); - - /** Patch hardcoded DOS paths (C:\dir\ → .\) in known game config files within a .jsdos bundle. */ public boolean fixBundleConfigPaths(String id) throws IOException { Game game = load(id); String bundleFile = game.getBundleFile(); @@ -226,8 +216,42 @@ public class GameService { Path bundlePath = gamesDir.resolve(bundleFile); if (!Files.exists(bundlePath)) return false; + // Phase 1: build a set of all entry paths for existence checking + java.util.Set allEntries = new java.util.HashSet<>(); + java.util.Set dirs = new java.util.HashSet<>(); + java.util.List textEntries = new java.util.ArrayList<>(); + try (var zis = new java.util.zip.ZipInputStream(Files.newInputStream(bundlePath))) { + java.util.zip.ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + String name = entry.getName(); + allEntries.add(name); + // Add all parent directories too + int idx = name.lastIndexOf('/'); + while (idx >= 0) { + dirs.add(name.substring(0, idx + 1)); + idx = name.lastIndexOf('/', idx - 1); + } + if (!entry.isDirectory() && isSmallTextEntry(entry, zis)) { + byte[] data = zis.readAllBytes(); + String text = new String(data, java.nio.charset.StandardCharsets.ISO_8859_1); + if (hasDrivePath(text)) { + textEntries.add(new EntryData(name, data)); + } + } + zis.closeEntry(); + } + } + + if (textEntries.isEmpty()) return false; + + // Build the existence checker using ZIP entry paths + var existsChecker = new ZipExistenceChecker(allEntries, dirs); + + // Phase 2: patch matching entries and write new ZIP Path tmpPath = bundlePath.resolveSibling(bundlePath.getFileName() + ".tmp"); boolean patched = false; + java.util.regex.Pattern drvPattern = java.util.regex.Pattern.compile( + "[A-Za-z]:(\\\\|/)[^\\s\"\\r\\n]+"); try (var zis = new java.util.zip.ZipInputStream(Files.newInputStream(bundlePath)); var zos = new java.util.zip.ZipOutputStream(Files.newOutputStream(tmpPath))) { @@ -235,25 +259,15 @@ public class GameService { java.util.zip.ZipEntry entry; while ((entry = zis.getNextEntry()) != null) { String name = entry.getName(); - String loName = name.toLowerCase(); - var prefixes = CONFIG_PATH_FIXES.get(loName); zos.putNextEntry(new java.util.zip.ZipEntry(name)); - if (prefixes != null) { - // Read the entry content and patch paths - byte[] data = zis.readAllBytes(); - byte[] result = data; - for (String prefix : prefixes) { - byte[] pBytes = prefix.getBytes(java.nio.charset.StandardCharsets.ISO_8859_1); - byte[] pSlash = prefix.replace('\\', '/') - .getBytes(java.nio.charset.StandardCharsets.ISO_8859_1); - byte[] rel = ".\\".getBytes(java.nio.charset.StandardCharsets.ISO_8859_1); - byte[] relSlash = "./" - .getBytes(java.nio.charset.StandardCharsets.ISO_8859_1); - result = replaceBytesInPlace(result, pBytes, rel); - result = replaceBytesInPlace(result, pSlash, relSlash); - } - if (!java.util.Arrays.equals(result, data)) { + // Check if this entry needs patching + var ed = findEntryData(textEntries, name); + if (ed != null) { + String text = new String(ed.data, java.nio.charset.StandardCharsets.ISO_8859_1); + String patchedText = patchZipPaths(text, existsChecker, drvPattern); + byte[] result = patchedText.getBytes(java.nio.charset.StandardCharsets.ISO_8859_1); + if (!java.util.Arrays.equals(result, ed.data)) { patched = true; } zos.write(result); @@ -273,26 +287,125 @@ public class GameService { return patched; } - private static byte[] replaceBytesInPlace(byte[] src, byte[] search, byte[] replacement) { - if (search.length == 0) return src; - java.util.ArrayList result = new java.util.ArrayList<>(src.length); - int i = 0; - while (i <= src.length - search.length) { - boolean match = true; - for (int j = 0; j < search.length; j++) { - if (src[i + j] != search[j]) { match = false; break; } - } - if (match) { - for (byte b : replacement) result.add(b); - i += search.length; - } else { - result.add(src[i++]); - } + private static boolean isSmallTextEntry(java.util.zip.ZipEntry entry, java.util.zip.ZipInputStream zis) { + if (entry.isDirectory()) return false; + long size = entry.getSize(); + if (size > 100 * 1024 || size == 0) return true; // unknown size is OK + return true; + } + + private static boolean hasDrivePath(String text) { + return java.util.regex.Pattern.compile("[A-Za-z]:\\\\").matcher(text).find(); + } + + private static EntryData findEntryData(java.util.List list, String name) { + for (var ed : list) { + if (ed.name.equals(name)) return ed; } - while (i < src.length) result.add(src[i++]); - byte[] out = new byte[result.size()]; - for (int k = 0; k < out.length; k++) out[k] = result.get(k); - return out; + return null; + } + + private static class EntryData { + final String name; + final byte[] data; + EntryData(String name, byte[] data) { this.name = name; this.data = data; } + } + + /** Case-insensitive existence check for paths inside a ZIP bundle. */ + private static class ZipExistenceChecker { + final java.util.Set entries; // all entries in original case + final java.util.Set dirs; // all directory prefixes + + ZipExistenceChecker(java.util.Set entries, java.util.Set dirs) { + this.entries = entries; + this.dirs = dirs; + } + + boolean exists(String path) { + // Check both forward and backslash variants + String fwd = path.replace('\\', '/'); + String bwd = path.replace('/', '\\'); + // Check exact match + if (entries.contains(fwd) || entries.contains(bwd)) return true; + // Check case-insensitively + for (String e : entries) { + if (e.equalsIgnoreCase(fwd) || e.equalsIgnoreCase(bwd)) return true; + } + // Check as directory (trailing slash variants) + String fwdDir = fwd.endsWith("/") ? fwd : fwd + "/"; + String bwdDir = bwd.endsWith("\\") ? bwd : bwd + "\\"; + if (dirs.contains(fwdDir) || dirs.contains(bwdDir)) return true; + for (String d : dirs) { + if (d.equalsIgnoreCase(fwdDir) || d.equalsIgnoreCase(bwdDir)) return true; + } + return false; + } + } + + /** Patch absolute DOS paths in ZIP entry text using the ZIP's own entry index. */ + private static String patchZipPaths(String text, ZipExistenceChecker checker, + java.util.regex.Pattern drvPattern) { + var m = drvPattern.matcher(text); + var sb = new StringBuilder(); + int lastEnd = 0; + java.util.Set seenPrefixes = new java.util.HashSet<>(); + + while (m.find()) { + sb.append(text, lastEnd, m.start()); + String fullPath = m.group(); + String drive = fullPath.substring(0, 2); + + if (!"C:".equalsIgnoreCase(drive)) { + sb.append(fullPath); + lastEnd = m.end(); + continue; + } + + String sep = fullPath.substring(2, 3); + String relPath = fullPath.substring(3); + String separator = "\\".equals(sep) ? "\\\\" : "/"; + String[] parts = relPath.split(separator, -1); + + boolean found = false; + for (int skip = 0; skip < parts.length; skip++) { + StringBuilder suffix = new StringBuilder(); + for (int j = skip; j < parts.length; j++) { + if (j > skip) suffix.append(sep); + suffix.append(parts[j]); + } + String suffixStr = suffix.toString(); + if (suffixStr.isEmpty()) continue; + + if (checker.exists(suffixStr)) { + if (skip == 0) { + sb.append(fullPath); // path valid + } else { + StringBuilder stale = new StringBuilder(drivePrefix(parts, skip, sep)); + String staleStr = stale.toString(); + if (!seenPrefixes.contains(staleStr)) { + seenPrefixes.add(staleStr); + } + sb.append(".").append(sep).append(suffixStr); + } + found = true; + break; + } + } + if (!found) sb.append(fullPath); + lastEnd = m.end(); + } + sb.append(text.substring(lastEnd)); + return sb.toString(); + } + + private static String drivePrefix(String[] parts, int skip, String sep) { + StringBuilder sb = new StringBuilder("C:").append(sep); + for (int k = 0; k < skip; k++) { + if (k > 0) sb.append(sep); + sb.append(parts[k]); + } + sb.append(sep); + return sb.toString(); } /** Scan a .jsdos bundle for CD image files (preserves the case from the ZIP entry). */ diff --git a/src/main/java/com/dostalgia/UploadResource.java b/src/main/java/com/dostalgia/UploadResource.java index 9e6566d..b12851b 100644 --- a/src/main/java/com/dostalgia/UploadResource.java +++ b/src/main/java/com/dostalgia/UploadResource.java @@ -480,76 +480,187 @@ public class UploadResource { } } - /** Known game config files that may have hardcoded absolute DOS paths. - * Key = filename pattern (lowercase), Value = list of path prefixes to - * replace with ".\\" (relative to the game root). */ - private static final java.util.Map> CONFIG_PATH_FIXES = - java.util.Map.of( - "fallout.cfg", java.util.List.of( - "c:\\fallout1\\", "C:\\fallout1\\", - "c:\\fallout2\\", "C:\\fallout2\\", - "c:\\fallout\\", "C:\\fallout\\" - ) - ); - /** - * Patch known game config files that contain hardcoded absolute DOS paths - * like {@code master_dat=C:\fallout1\master.dat}. After flattening moves - * everything to the bundle root, these paths break. Rewrites them to - * relative {@code .\master.dat} instead. + * Detect and fix hardcoded absolute DOS paths in game config files. + * + * Scans all text files in the extracted game for patterns like + * {@code master_dat=C:\FALLOUT1\MASTER.DAT} or {@code C:/FALLOUT1/MASTER.DAT}. + * If the referenced path doesn't exist in the extracted tree but IS found + * at root level (after flattening), rewrites the stale directory prefix + * from {@code C:\STALEDIR\} to {@code .\} (relative to bundle root). + * + * This is fully game-agnostic — no hardcoded game names or known config files. + * Only processes C: drive references; D:/E:/etc. are left alone (CD-ROM refs). */ private static void fixAbsoluteConfigPaths(Path extractDir) throws IOException { + java.util.regex.Pattern drvPattern = java.util.regex.Pattern.compile( + "[A-Za-z]:(\\\\|/)[^\\s\"\\r\\n]+"); try (var walk = Files.walk(extractDir)) { - walk.filter(Files::isRegularFile).forEach(f -> { - String name = f.getFileName().toString().toLowerCase(); - var prefixes = CONFIG_PATH_FIXES.get(name); - if (prefixes == null) return; - try { - byte[] data = Files.readAllBytes(f); - byte[] original = data.clone(); - for (String prefix : prefixes) { - // Replace both "C:\dir\" and "c:\dir\" patterns - byte[] pBytes = prefix.getBytes(java.nio.charset.StandardCharsets.ISO_8859_1); - byte[] pSlash = prefix.replace('\\', '/') - .getBytes(java.nio.charset.StandardCharsets.ISO_8859_1); - byte[] rel = ".\\".getBytes(java.nio.charset.StandardCharsets.ISO_8859_1); - byte[] relSlash = "./" - .getBytes(java.nio.charset.StandardCharsets.ISO_8859_1); - data = replaceBytes(data, pBytes, rel); - data = replaceBytes(data, pSlash, relSlash); + walk.filter(Files::isRegularFile) + .filter(f -> isSmallTextFile(f)) + .forEach(f -> { + try { + byte[] data = Files.readAllBytes(f); + byte[] original = data.clone(); + String text = new String(data, java.nio.charset.StandardCharsets.ISO_8859_1); + String patched = patchAbsolutePaths(text, extractDir, drvPattern); + if (!patched.equals(text)) { + Files.write(f, patched.getBytes( + java.nio.charset.StandardCharsets.ISO_8859_1)); + LOG.info("Patched absolute paths in " + f.getFileName()); + } + } catch (IOException e) { + LOG.warning("Failed to patch " + f.getFileName() + ": " + e.getMessage()); } - if (!java.util.Arrays.equals(data, original)) { - Files.write(f, data); - LOG.info("Patched absolute paths in " + f.getFileName()); - } - } catch (IOException e) { - LOG.warning("Failed to patch " + f.getFileName() + ": " + e.getMessage()); - } - }); + }); } } - /** Simple byte-level search-and-replace (preserves CRLF, encoding, etc.). */ - private static byte[] replaceBytes(byte[] src, byte[] search, byte[] replacement) { - if (search.length == 0) return src; - java.util.ArrayList result = new java.util.ArrayList<>(src.length); - int i = 0; - while (i <= src.length - search.length) { - boolean match = true; - for (int j = 0; j < search.length; j++) { - if (src[i + j] != search[j]) { match = false; break; } - } - if (match) { - for (byte b : replacement) result.add(b); - i += search.length; - } else { - result.add(src[i++]); + /** Returns true if the file is small (<100KB) and contains no null bytes (i.e. is text). */ + private static boolean isSmallTextFile(Path f) throws IOException { + long size = Files.size(f); + if (size > 100 * 1024 || size == 0) return false; + try (var is = Files.newInputStream(f)) { + int len = (int) Math.min(size, 4096); + byte[] head = new byte[len]; + int read = is.readNBytes(head, 0, len); + for (int i = 0; i < read; i++) { + if (head[i] == 0) return false; } } - while (i < src.length) result.add(src[i++]); - byte[] out = new byte[result.size()]; - for (int k = 0; k < out.length; k++) out[k] = result.get(k); - return out; + return true; + } + + /** + * Find absolute DOS paths (C:\dir\file) in the text and rewrite stale + * directory prefixes to {@code .\} where the file/dir is found at root + * level after flattening. + */ + private static String patchAbsolutePaths(String text, Path extractDir, + java.util.regex.Pattern drvPattern) { + var m = drvPattern.matcher(text); + var sb = new StringBuilder(); + int lastEnd = 0; + java.util.Set seenPrefixes = new java.util.HashSet<>(); + + while (m.find()) { + int start = m.start(); + // Copy text up to this match + sb.append(text, lastEnd, start); + + String fullPath = m.group(); + String drive = fullPath.substring(0, 2); // "C:" or "D:" etc. + + // Only rewrite C: drive references + if (!"C:".equalsIgnoreCase(drive)) { + sb.append(fullPath); + lastEnd = m.end(); + continue; + } + + // Try to find what exists after flattening + String replacement = findStalePrefixReplace(fullPath, extractDir, seenPrefixes); + sb.append(replacement); + lastEnd = m.end(); + } + sb.append(text.substring(lastEnd)); + return sb.toString(); + } + + /** + * Given a full path like {@code C:\FALLOUT1\CRITTER.DAT}, try progressively + * shorter suffixes against the extracted directory tree. When a suffix is + * found, rewrite the stale prefix to {@code .\}. + * + * Example: {@code C:\FALLOUT1\CRITTER.DAT} → CRITTER.DAT exists at root → + * stale prefix is {@code C:\FALLOUT1\} → returns {@code .\CRITTER.DAT} + */ + private static String findStalePrefixReplace(String fullPath, Path extractDir, + java.util.Set seenPrefixes) { + String drivePrefix = fullPath.substring(0, 2); // "C:" + String sep = fullPath.substring(2, 3); // "\" or "/" + String relPath = fullPath.substring(3); // everything after "C:\" + + // Split into components + String separator = "\\".equals(sep) ? "\\\\" : "/"; + String[] parts = relPath.split(separator, -1); + // parts = ["FALLOUT1", "CRITTER.DAT"] or ["FALLOUT1", "DATA", "SOUND", "MUSIC", ""] + // The last element is empty if the path ends with separator (directory ref) + + // Try suffixes from longest to shortest + for (int skip = 0; skip < parts.length; skip++) { + // Build suffix: parts[skip] + sep + parts[skip+1] + ... + StringBuilder suffix = new StringBuilder(); + for (int j = skip; j < parts.length; j++) { + if (j > skip) suffix.append(sep); + suffix.append(parts[j]); + } + String suffixStr = suffix.toString(); + if (suffixStr.isEmpty()) continue; + + // Check if this suffix exists in the extracted directory (case-insensitive) + if (existsIgnoreCase(extractDir, suffixStr)) { + // The path is valid — keep it as-is + if (skip == 0) return fullPath; + + // This suffix exists at root; build the replacement + // stale prefix = C:\ + parts[0..skip-1] + separator + // replacement = .\ + suffixStr + // But avoid double-replacing the same prefix + StringBuilder stale = new StringBuilder(drivePrefix + sep); + for (int k = 0; k < skip; k++) { + if (k > 0) stale.append(sep); + stale.append(parts[k]); + } + stale.append(sep); + + String staleStr = stale.toString(); + if (seenPrefixes.contains(staleStr)) { + // Already replaced elsewhere; just return the relative form + return "." + sep + suffixStr; + } + seenPrefixes.add(staleStr); + return "." + sep + suffixStr; + } + } + + // Nothing matched — return the path unchanged + return fullPath; + } + + /** + * Case-insensitive path existence check. Walks the path component by + * component, matching each directory/file name case-insensitively against + * the actual filesystem entries. + */ + private static boolean existsIgnoreCase(Path base, String relativePath) { + String sep = relativePath.contains("\\") ? "\\\\" : "/"; + String[] parts = relativePath.split(sep, -1); + Path current = base; + try { + for (int i = 0; i < parts.length; i++) { + String part = parts[i]; + if (part.isEmpty()) { + // Trailing separator means directory — check if current is a dir + if (i == parts.length - 1) return Files.isDirectory(current); + continue; + } + Path found = null; + try (var dirStream = Files.list(current)) { + for (Path entry : (Iterable) dirStream::iterator) { + if (entry.getFileName().toString().equalsIgnoreCase(part)) { + found = entry; + break; + } + } + } + if (found == null) return false; + current = found; + } + } catch (IOException e) { + return false; + } + return true; } /** Find mountable CD image files in the extracted game directory.