feat: add Sierra game bundle patcher and API endpoint
Build & Deploy / build-and-deploy (push) Successful in 41s
Build & Deploy / build-and-deploy (push) Successful in 41s
Handles common Sierra game file layout issues:
- RESOURCE.CFG with directory paths pointing to \KQ6CD when
resources are actually at the bundle root (fixes paths to '.')
- INTERP.ERR/INTERP.ERRC files buried in subdirectories instead
of at root (copies them up so SIERRA.EXE can find them)
POST /api/games/{id}/patch-sierra triggers the fix on an existing
game bundle.
This commit is contained in:
@@ -131,6 +131,23 @@ public class GameResource {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch a Sierra game bundle to fix common file layout issues
|
||||
* (INTERP.ERR paths, RESOURCE.CFG directories).
|
||||
*/
|
||||
@POST
|
||||
@Path("/{id}/patch-sierra")
|
||||
public Response patchSierra(@PathParam("id") String id) {
|
||||
try {
|
||||
var patched = svc.patchSierraGame(id);
|
||||
return Response.ok(Map.of("patched", patched)).build();
|
||||
} catch (NoSuchFileException e) {
|
||||
return Response.status(404).entity(Map.of("error", "Game not found")).build();
|
||||
} catch (Exception e) {
|
||||
return Response.serverError().entity(Map.of("error", e.getMessage())).build();
|
||||
}
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/{id}/download")
|
||||
@Produces("application/zip")
|
||||
|
||||
@@ -322,5 +322,154 @@ public class GameService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch a .jsdos bundle for Sierra games that have file layout issues:
|
||||
* - INTERP.ERR(C) in a subdirectory instead of root (causes "Can't find" errors)
|
||||
* - RESOURCE.CFG directory paths pointing to wrong locations
|
||||
* Returns the list of files that were modified.
|
||||
*/
|
||||
public List<String> patchSierraGame(String id) throws IOException {
|
||||
Game game = load(id);
|
||||
Path bundlePath = gamesDir.resolve(game.getBundleFile());
|
||||
if (!Files.exists(bundlePath)) {
|
||||
throw new NoSuchFileException("Bundle not found: " + game.getBundleFile());
|
||||
}
|
||||
|
||||
Path tmpPath = bundlePath.resolveSibling(bundlePath.getFileName() + ".patchtmp");
|
||||
List<String> patched = new ArrayList<>();
|
||||
|
||||
try (var zis = new java.util.zip.ZipInputStream(Files.newInputStream(bundlePath));
|
||||
var zos = new java.util.zip.ZipOutputStream(Files.newOutputStream(tmpPath))) {
|
||||
|
||||
// First pass: read all entries to find INTERP files in subdirs
|
||||
// We can't buffer the whole zip, so we do the copy in one pass
|
||||
// and apply known fixes on the fly.
|
||||
|
||||
// Read the RESOURCE.CFG for Sierra directory detection
|
||||
// We do this by pre-scanning the zip (streaming approach)
|
||||
|
||||
java.util.zip.ZipEntry entry;
|
||||
while ((entry = zis.getNextEntry()) != null) {
|
||||
String name = entry.getName();
|
||||
zos.putNextEntry(new java.util.zip.ZipEntry(name));
|
||||
|
||||
if (name.equals("RESOURCE.CFG")) {
|
||||
// Read, patch, write
|
||||
byte[] content = zis.readAllBytes();
|
||||
String text = new String(content, java.nio.charset.StandardCharsets.ISO_8859_1);
|
||||
String original = text;
|
||||
|
||||
// Fix directory paths pointing to a subdir when resources are at root
|
||||
text = text.replaceAll("(?m)^directory\\s*=\\\\?.+", "directory = .");
|
||||
text = text.replaceAll("(?m)^audio\\s*=\\s*[A-Za-z]:\\\\?.+", "audio = .");
|
||||
text = text.replaceAll("(?m)^sync\\s*=\\s*[A-Za-z]:\\\\?.+", "sync = .");
|
||||
text = text.replaceAll("(?m)^movieDir\\s*=\\s*[A-Za-z]:\\\\?.+", "movieDir = .\\\\SEQ");
|
||||
text = text.replaceAll("(?m)^patchDir\\s*=\\s*[A-Za-z]:\\\\?.+", "patchDir = .");
|
||||
|
||||
if (!text.equals(original)) {
|
||||
patched.add("RESOURCE.CFG (fixed paths)");
|
||||
zos.write(text.getBytes(java.nio.charset.StandardCharsets.ISO_8859_1));
|
||||
} else {
|
||||
zos.write(content);
|
||||
}
|
||||
} else {
|
||||
// For all other files, check if we need to add INTERP.ERR(C) copies
|
||||
zis.transferTo(zos);
|
||||
}
|
||||
zos.closeEntry();
|
||||
zis.closeEntry();
|
||||
}
|
||||
|
||||
// Second pass: scan for INTERP files in subdirectories
|
||||
// We need to re-read the zip for this. Use a clean approach.
|
||||
}
|
||||
|
||||
// If RESOURCE.CFG was not patched, check for INTERP.ERR and add it
|
||||
boolean needsInterpFix = false;
|
||||
String interpSource = null;
|
||||
|
||||
// Re-scan the original bundle for INTERP files in subdirs
|
||||
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();
|
||||
if (!name.startsWith(".jsdos/") && name.contains("/") &&
|
||||
(name.endsWith("/INTERP.ERR") || name.endsWith("/INTERP.ERRC"))) {
|
||||
// Found an INTERP file in a subdirectory
|
||||
String rootName = name.substring(name.lastIndexOf('/') + 1);
|
||||
interpSource = name;
|
||||
break;
|
||||
}
|
||||
zis.closeEntry();
|
||||
}
|
||||
}
|
||||
|
||||
if (interpSource != null) {
|
||||
// Check if INTERP file already exists at root in the patched bundle
|
||||
boolean hasRootInterp = false;
|
||||
try (var zis = new java.util.zip.ZipInputStream(Files.newInputStream(tmpPath))) {
|
||||
java.util.zip.ZipEntry entry;
|
||||
while ((entry = zis.getNextEntry()) != null) {
|
||||
String name = entry.getName();
|
||||
if (name.equals("INTERP.ERR") || name.equals("INTERP.ERRC")) {
|
||||
hasRootInterp = true;
|
||||
break;
|
||||
}
|
||||
zis.closeEntry();
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasRootInterp) {
|
||||
// Read the INTERP file from original bundle and append it
|
||||
byte[] interpData = null;
|
||||
try (var zis = new java.util.zip.ZipInputStream(Files.newInputStream(bundlePath))) {
|
||||
java.util.zip.ZipEntry entry;
|
||||
while ((entry = zis.getNextEntry()) != null) {
|
||||
if (entry.getName().equals(interpSource)) {
|
||||
interpData = zis.readAllBytes();
|
||||
break;
|
||||
}
|
||||
zis.closeEntry();
|
||||
}
|
||||
}
|
||||
|
||||
if (interpData != null) {
|
||||
// Append to the patched bundle
|
||||
String rootName = interpSource.substring(interpSource.lastIndexOf('/') + 1);
|
||||
try (var zis2 = new java.util.zip.ZipInputStream(Files.newInputStream(tmpPath));
|
||||
var tmp2 = new java.util.zip.ZipOutputStream(Files.newOutputStream(
|
||||
tmpPath.resolveSibling(bundlePath.getFileName() + ".patchtmp2")))) {
|
||||
java.util.zip.ZipEntry e;
|
||||
while ((e = zis2.getNextEntry()) != null) {
|
||||
tmp2.putNextEntry(new java.util.zip.ZipEntry(e.getName()));
|
||||
zis2.transferTo(tmp2);
|
||||
tmp2.closeEntry();
|
||||
zis2.closeEntry();
|
||||
}
|
||||
// Add INTERP file at root
|
||||
tmp2.putNextEntry(new java.util.zip.ZipEntry(rootName));
|
||||
tmp2.write(interpData);
|
||||
tmp2.closeEntry();
|
||||
zis2.close();
|
||||
tmp2.close();
|
||||
}
|
||||
Files.delete(tmpPath);
|
||||
Path tmp2 = tmpPath.resolveSibling(bundlePath.getFileName() + ".patchtmp2");
|
||||
Files.move(tmp2, tmpPath);
|
||||
patched.add(rootName + " (copied from " + interpSource + ")");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If nothing was patched, clean up and return empty
|
||||
if (patched.isEmpty()) {
|
||||
Files.deleteIfExists(tmpPath);
|
||||
return patched;
|
||||
}
|
||||
|
||||
Files.move(tmpPath, bundlePath, StandardCopyOption.REPLACE_EXISTING);
|
||||
return patched;
|
||||
}
|
||||
|
||||
public Path getGamesDir() { return gamesDir; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user