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
+162 -49
View File
@@ -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<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 {
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<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");
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<Byte> 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<EntryData> 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<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 {
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). */
+171 -60
View File
@@ -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<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
* 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<Byte> 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<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.