feat: make config path patcher fully game-agnostic
Build & Deploy / build-and-deploy (push) Failing after 46s

Removes hardcoded CONFIG_PATH_FIXES map with Fallout-specific entries.
Replaces with generic scanner that:
- Scans all small text files (<100KB) for X:\... patterns
- Only processes C: drive paths (D:/E:/ left alone as CD-ROM refs)
- Uses case-insensitive filesystem checks to find what exists after flattening
- Progressively tries shorter path suffixes to find the stale prefix
- Rewrites C:\STALEDIR\ → .\ only when the leaf file/dir is found at root
- Works with both \ and / path separators

Also refactors the GameService fixBundleConfigPaths to build a ZIP entry
index and use the same logic for existing bundle patching.
This commit is contained in:
David Alvarez
2026-06-08 11:40:40 +02:00
parent 82b379d211
commit efe0017756
2 changed files with 333 additions and 109 deletions
+159 -46
View File
@@ -205,20 +205,10 @@ public class GameService {
} }
/** /**
* Known game config files with hardcoded DOS paths that need fixing. * Patch hardcoded absolute DOS paths (C:\dir\ → .\) in game config files
* Same as UploadResource.CONFIG_PATH_FIXES but duplicated here for * within a .jsdos bundle. Game-agnostic — scans all small text entries
* the fix-paths endpoint (no dependency on UploadResource). * for drive-letter paths and rewrites stale directory prefixes.
*/ */
private static final java.util.Map<String, java.util.List<String>> 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 { public boolean fixBundleConfigPaths(String id) throws IOException {
Game game = load(id); Game game = load(id);
String bundleFile = game.getBundleFile(); String bundleFile = game.getBundleFile();
@@ -226,8 +216,42 @@ public class GameService {
Path bundlePath = gamesDir.resolve(bundleFile); Path bundlePath = gamesDir.resolve(bundleFile);
if (!Files.exists(bundlePath)) return false; if (!Files.exists(bundlePath)) return false;
// Phase 1: build a set of all entry paths for existence checking
java.util.Set<String> allEntries = new java.util.HashSet<>();
java.util.Set<String> dirs = new java.util.HashSet<>();
java.util.List<EntryData> 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"); Path tmpPath = bundlePath.resolveSibling(bundlePath.getFileName() + ".tmp");
boolean patched = false; 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)); try (var zis = new java.util.zip.ZipInputStream(Files.newInputStream(bundlePath));
var zos = new java.util.zip.ZipOutputStream(Files.newOutputStream(tmpPath))) { var zos = new java.util.zip.ZipOutputStream(Files.newOutputStream(tmpPath))) {
@@ -235,25 +259,15 @@ public class GameService {
java.util.zip.ZipEntry entry; java.util.zip.ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) { while ((entry = zis.getNextEntry()) != null) {
String name = entry.getName(); String name = entry.getName();
String loName = name.toLowerCase();
var prefixes = CONFIG_PATH_FIXES.get(loName);
zos.putNextEntry(new java.util.zip.ZipEntry(name)); zos.putNextEntry(new java.util.zip.ZipEntry(name));
if (prefixes != null) { // Check if this entry needs patching
// Read the entry content and patch paths var ed = findEntryData(textEntries, name);
byte[] data = zis.readAllBytes(); if (ed != null) {
byte[] result = data; String text = new String(ed.data, java.nio.charset.StandardCharsets.ISO_8859_1);
for (String prefix : prefixes) { String patchedText = patchZipPaths(text, existsChecker, drvPattern);
byte[] pBytes = prefix.getBytes(java.nio.charset.StandardCharsets.ISO_8859_1); byte[] result = patchedText.getBytes(java.nio.charset.StandardCharsets.ISO_8859_1);
byte[] pSlash = prefix.replace('\\', '/') if (!java.util.Arrays.equals(result, ed.data)) {
.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)) {
patched = true; patched = true;
} }
zos.write(result); zos.write(result);
@@ -273,26 +287,125 @@ public class GameService {
return patched; return patched;
} }
private static byte[] replaceBytesInPlace(byte[] src, byte[] search, byte[] replacement) { private static boolean isSmallTextEntry(java.util.zip.ZipEntry entry, java.util.zip.ZipInputStream zis) {
if (search.length == 0) return src; if (entry.isDirectory()) return false;
java.util.ArrayList<Byte> result = new java.util.ArrayList<>(src.length); long size = entry.getSize();
int i = 0; if (size > 100 * 1024 || size == 0) return true; // unknown size is OK
while (i <= src.length - search.length) { return true;
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); private static boolean hasDrivePath(String text) {
i += search.length; return java.util.regex.Pattern.compile("[A-Za-z]:\\\\").matcher(text).find();
}
private static EntryData findEntryData(java.util.List<EntryData> list, String name) {
for (var ed : list) {
if (ed.name.equals(name)) return ed;
}
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<String> entries; // all entries in original case
final java.util.Set<String> dirs; // all directory prefixes
ZipExistenceChecker(java.util.Set<String> entries, java.util.Set<String> 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<String> 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 { } else {
result.add(src[i++]); 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;
} }
} }
while (i < src.length) result.add(src[i++]); if (!found) sb.append(fullPath);
byte[] out = new byte[result.size()]; lastEnd = m.end();
for (int k = 0; k < out.length; k++) out[k] = result.get(k); }
return out; 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). */ /** Scan a .jsdos bundle for CD image files (preserves the case from the ZIP entry). */
+163 -52
View File
@@ -480,46 +480,33 @@ 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<String, java.util.List<String>> 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 * Detect and fix hardcoded absolute DOS paths in game config files.
* like {@code master_dat=C:\fallout1\master.dat}. After flattening moves *
* everything to the bundle root, these paths break. Rewrites them to * Scans all text files in the extracted game for patterns like
* relative {@code .\master.dat} instead. * {@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 { 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)) { try (var walk = Files.walk(extractDir)) {
walk.filter(Files::isRegularFile).forEach(f -> { walk.filter(Files::isRegularFile)
String name = f.getFileName().toString().toLowerCase(); .filter(f -> isSmallTextFile(f))
var prefixes = CONFIG_PATH_FIXES.get(name); .forEach(f -> {
if (prefixes == null) return;
try { try {
byte[] data = Files.readAllBytes(f); byte[] data = Files.readAllBytes(f);
byte[] original = data.clone(); byte[] original = data.clone();
for (String prefix : prefixes) { String text = new String(data, java.nio.charset.StandardCharsets.ISO_8859_1);
// Replace both "C:\dir\" and "c:\dir\" patterns String patched = patchAbsolutePaths(text, extractDir, drvPattern);
byte[] pBytes = prefix.getBytes(java.nio.charset.StandardCharsets.ISO_8859_1); if (!patched.equals(text)) {
byte[] pSlash = prefix.replace('\\', '/') Files.write(f, patched.getBytes(
.getBytes(java.nio.charset.StandardCharsets.ISO_8859_1); 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);
}
if (!java.util.Arrays.equals(data, original)) {
Files.write(f, data);
LOG.info("Patched absolute paths in " + f.getFileName()); LOG.info("Patched absolute paths in " + f.getFileName());
} }
} catch (IOException e) { } catch (IOException e) {
@@ -529,27 +516,151 @@ public class UploadResource {
} }
} }
/** Simple byte-level search-and-replace (preserves CRLF, encoding, etc.). */ /** Returns true if the file is small (<100KB) and contains no null bytes (i.e. is text). */
private static byte[] replaceBytes(byte[] src, byte[] search, byte[] replacement) { private static boolean isSmallTextFile(Path f) throws IOException {
if (search.length == 0) return src; long size = Files.size(f);
java.util.ArrayList<Byte> result = new java.util.ArrayList<>(src.length); if (size > 100 * 1024 || size == 0) return false;
int i = 0; try (var is = Files.newInputStream(f)) {
while (i <= src.length - search.length) { int len = (int) Math.min(size, 4096);
boolean match = true; byte[] head = new byte[len];
for (int j = 0; j < search.length; j++) { int read = is.readNBytes(head, 0, len);
if (src[i + j] != search[j]) { match = false; break; } for (int i = 0; i < read; i++) {
} if (head[i] == 0) return false;
if (match) {
for (byte b : replacement) result.add(b);
i += search.length;
} else {
result.add(src[i++]);
} }
} }
while (i < src.length) result.add(src[i++]); return true;
byte[] out = new byte[result.size()]; }
for (int k = 0; k < out.length; k++) out[k] = result.get(k);
return out; /**
* 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<String> 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<String> 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<Path>) 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. /** Find mountable CD image files in the extracted game directory.