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
|
@GET
|
||||||
@Path("/{id}/download")
|
@Path("/{id}/download")
|
||||||
@Produces("application/zip")
|
@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; }
|
public Path getGamesDir() { return gamesDir; }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user