Crappy support for Mystery Gifts (#42)

This commit is contained in:
kuroppoi 2024-07-06 23:44:30 +02:00
parent abe1b80c2d
commit acbb3d7f95
7 changed files with 143 additions and 40 deletions

View File

@ -3,5 +3,6 @@ package entralinked.model.dlc;
/**
* Simple record for DLC data.
*/
@Deprecated
public record Dlc(String path, String name, String gameCode, String type,
int index, int projectedSize, int checksum, boolean checksumEmbedded) {}

View File

@ -18,6 +18,7 @@ import org.apache.logging.log4j.Logger;
import entralinked.utility.Crc16;
@Deprecated
public class DlcList {
private static final Logger logger = LogManager.getLogger();

View File

@ -1,18 +1,17 @@
package entralinked.model.user;
import java.io.File;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import entralinked.model.dlc.Dlc;
public class User {
private final String id;
private final String password; // I debated hashing it, but.. it's a 3-digit password...
private final Map<String, GameProfile> profiles = new HashMap<>();
private final Map<String, Dlc> dlcOverrides = new HashMap<>();
private final Map<String, File> dlcOverrides = new HashMap<>();
private int profileIdOverride; // For making it easier for the user to fix error 60000
public User(String id, String password) {
@ -59,11 +58,11 @@ public class User {
return Collections.unmodifiableMap(profiles);
}
public void setDlcOverride(String type, Dlc target) {
if(target == null) {
public void setDlcOverride(String type, File file) {
if(file == null) {
dlcOverrides.remove(type);
} else {
dlcOverrides.put(type, target);
dlcOverrides.put(type, file);
}
}
@ -75,7 +74,7 @@ public class User {
return dlcOverrides.containsKey(type);
}
public Dlc getDlcOverride(String type) {
public File getDlcOverride(String type) {
return dlcOverrides.get(type);
}

View File

@ -1,7 +1,9 @@
package entralinked.network.http.dls;
import java.io.FileInputStream;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.Arrays;
import java.util.List;
import org.apache.logging.log4j.LogManager;
@ -10,15 +12,14 @@ import org.apache.logging.log4j.Logger;
import com.fasterxml.jackson.databind.ObjectMapper;
import entralinked.Entralinked;
import entralinked.model.dlc.Dlc;
import entralinked.model.dlc.DlcList;
import entralinked.GameVersion;
import entralinked.model.user.ServiceSession;
import entralinked.model.user.User;
import entralinked.model.user.UserManager;
import entralinked.network.http.HttpHandler;
import entralinked.network.http.HttpRequestHandler;
import entralinked.serialization.UrlEncodedFormFactory;
import entralinked.utility.LEOutputStream;
import entralinked.utility.MysteryGiftUtility;
import io.javalin.Javalin;
import io.javalin.http.Context;
import io.javalin.http.HttpStatus;
@ -30,11 +31,10 @@ public class DlsHandler implements HttpHandler {
private static final Logger logger = LogManager.getLogger();
private final ObjectMapper mapper = new ObjectMapper(new UrlEncodedFormFactory());
private final DlcList dlcList;
private final File rootDirectory = new File("dlc");
private final UserManager userManager;
public DlsHandler(Entralinked entralinked) {
this.dlcList = entralinked.getDlcList();
this.userManager = entralinked.getUserManager();
}
@ -64,6 +64,7 @@ public class DlsHandler implements HttpHandler {
HttpRequestHandler<DlsRequest> handler = switch(request.action()) {
case "list" -> this::handleRetrieveDlcList;
case "contents" -> this::handleRetrieveDlcContent;
case "count" -> this::handleRetrieveDlcCount;
default -> throw new IllegalArgumentException("Invalid POST request action: " + request.action());
};
@ -81,15 +82,43 @@ public class DlsHandler implements HttpHandler {
User user = ctx.attribute("user");
String gameCode = getDlcGameCode(request.dlcGameCode());
String type = getRegionlessDlcType(request.dlcType());
String attr2 = request.attr2();
List<File> files = user.hasDlcOverride(type) ? Arrays.asList(user.getDlcOverride(type))
: Arrays.asList(getDlcDirectory(gameCode, type).listFiles());
// If an overriding DLC is present, send the data for that instead.
if(user.hasDlcOverride(type)) {
ctx.result(dlcList.getDlcListString(List.of(user.getDlcOverride(type))));
// Return empty string if no DLC could be found
if(files == null) {
ctx.result("");
return;
}
// TODO NOTE: I assume that in a conventional implementation, certain DLC attributes may be omitted from the request.
ctx.result(dlcList.getDlcListString(dlcList.getDlcList(gameCode, type, request.dlcIndex())));
// PGL content attr2 hack
if(attr2 != null) {
files = Arrays.asList(files.get(Integer.parseInt(attr2) - 1));
}
StringBuilder builder = new StringBuilder();
int count = Math.min(files.size(), request.num());
// Create DLC list string
for(int i = 0; i < count; i++) {
File file = files.get(i);
if(type == null) {
// Generation 4 Mystery Gift
builder.append("%s\t\t\t\t\t%s\r\n".formatted(file.getName(), file.length()));
} else if(type.equals("MYSTERY")) {
// Generation 5 Mystery Gift
String gameFlag = GameVersion.lookup(request.gameCode()).isVersion2() ? "F00000" : "300000";
builder.append("%s\t\t%s\t%s\t\t%s\r\n".formatted(file.getName(), type, gameFlag, 720));
} else {
// PGL content
builder.append("%s\t\t%s\t%s\t\t%s\r\n".formatted(file.getName(), type, i + 1, file.length()));
}
}
// Send result
ctx.result(builder.toString());
}
/**
@ -99,32 +128,44 @@ public class DlsHandler implements HttpHandler {
User user = ctx.attribute("user");
String gameCode = getDlcGameCode(request.dlcGameCode());
String type = getRegionlessDlcType(request.dlcType());
Dlc dlc = user.hasDlcOverride(type) ? user.getDlcOverride(type) : dlcList.getDlc(gameCode, type, request.dlcName());
File file = user.hasDlcOverride(type) ? user.getDlcOverride(type) : type != null
? new File(rootDirectory, "%s/%s/%s".formatted(gameCode, type, request.dlcName()))
: new File(rootDirectory, "%s/%s".formatted(gameCode, request.dlcName()));
// Check if the requested DLC exists
if(dlc == null) {
if(!file.exists()) {
ctx.status(HttpStatus.NOT_FOUND);
return;
}
// Write DLC data
try(FileInputStream inputStream = new FileInputStream(dlc.path())) {
LEOutputStream outputStream = new LEOutputStream(ctx.outputStream());
inputStream.transferTo(outputStream);
// If checksum is not part of the file, manually append it
if(!dlc.checksumEmbedded()) {
outputStream.writeShort(dlc.checksum());
}
byte[] bytes = Files.readAllBytes(file.toPath());
if(type == null) {
// Generation 4 Mystery Gift
bytes = MysteryGiftUtility.createUniversalGiftData4(bytes);
} else if(type.equals("MYSTERY")) {
// Generation 5 Mystery Gift
bytes = MysteryGiftUtility.createUniversalGiftData5(bytes);
}
// Send result
ctx.result(bytes);
}
/**
* POST handler for {@code /download action=count}
*/
private void handleRetrieveDlcCount(DlsRequest request, Context ctx) throws IOException {
ctx.result("1"); // TODO
}
/**
* @return The game serial that should be used for downloading DLC based on the provided input.
*/
private String getDlcGameCode(String gameCode) {
return switch(gameCode) {
case "IRAJ" -> "IRAO";
return switch(gameCode.substring(0, 3)) {
case "IRA" -> "IRAO"; // BW & B2W2
case "ADA", "CPU", "IPG" -> "ADAE"; // DPPt & HGSS
default -> gameCode;
};
}
@ -133,12 +174,21 @@ public class DlsHandler implements HttpHandler {
* @return The DLC type without the region identifier, or the input if it is an unknown type.
*/
private String getRegionlessDlcType(String dlcType) {
if(dlcType == null) {
return null;
}
return switch(dlcType) {
case "CGEAR_E", "CGEAR_F", "CGEAR_I", "CGEAR_G", "CGEAR_S", "CGEAR_J", "CGEAR_K" -> "CGEAR";
case "CGEAR2_E", "CGEAR2_F", "CGEAR2_I", "CGEAR2_G", "CGEAR2_S", "CGEAR2_J", "CGEAR2_K" -> "CGEAR2";
case "ZUKAN_E", "ZUKAN_F", "ZUKAN_I", "ZUKAN_G", "ZUKAN_S", "ZUKAN_J", "ZUKAN_K" -> "ZUKAN";
case "MUSICAL_E", "MUSICAL_F", "MUSICAL_I", "MUSICAL_G", "MUSICAL_S", "MUSICAL_J", "MUSICAL_K" -> "MUSICAL";
case "MYSTERY_E", "MYSTERY_F", "MYSTERY_I", "MYSTERY_G", "MYSTERY_S", "MYSTERY_J", "MYSTERY_K" -> "MYSTERY";
default -> dlcType;
};
}
private File getDlcDirectory(String gameCode, String dlcType) {
return dlcType == null ? new File(rootDirectory, gameCode) : new File(rootDirectory, "%s/%s".formatted(gameCode, dlcType));
}
}

View File

@ -20,13 +20,13 @@ public record DlsRequest(
@JsonProperty("gamecd") String dlcGameCode,
@JsonProperty("contents") String dlcName, // action=contents
@JsonProperty("attr1") String dlcType, // action=list
@JsonProperty("attr2") int dlcIndex, // action=list
@JsonProperty("attr2") String attr2, // action=list
@JsonProperty("offset") int offset, // Start offset in the list
@JsonProperty("num") int num) { // Number of entries
@Override
public String toString() {
return ("DlsRequest[gameCode=%s, action=%s, dlcGameCode=%s, dlcName=%s, dlcType=%s, dlcIndex=%s, offset=%s, num=%s]")
.formatted(gameCode, action, dlcGameCode, dlcName, dlcType, dlcIndex, offset, num);
return ("DlsRequest[gameCode=%s, action=%s, dlcGameCode=%s, dlcName=%s, dlcType=%s, attr2=%s, offset=%s, num=%s]")
.formatted(gameCode, action, dlcGameCode, dlcName, dlcType, attr2, offset, num);
}
}

View File

@ -17,7 +17,6 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import entralinked.Configuration;
import entralinked.Entralinked;
import entralinked.model.avenue.AvenueVisitor;
import entralinked.model.dlc.Dlc;
import entralinked.model.dlc.DlcList;
import entralinked.model.pkmn.PkmnInfo;
import entralinked.model.pkmn.PkmnInfoReader;
@ -226,8 +225,7 @@ public class PglHandler implements HttpHandler {
// Create or remove custom C-Gear skin DLC override
if("custom".equals(cgearSkin)) {
cgearSkinIndex = 1;
user.setDlcOverride(cgearType, new Dlc(player.getCGearSkinFile().getAbsolutePath(),
"custom", "IRAO", cgearType, cgearSkinIndex, 9730, 0, true));
user.setDlcOverride(cgearType, player.getCGearSkinFile());
} else {
cgearSkinIndex = dlcList.getDlcIndex("IRAO", cgearType, cgearSkin);
user.removeDlcOverride(cgearType);
@ -236,8 +234,7 @@ public class PglHandler implements HttpHandler {
// Create or remove custom Pokédex skin DLC override
if("custom".equals(dexSkin)) {
dexSkinIndex = 1;
user.setDlcOverride("ZUKAN", new Dlc(player.getDexSkinFile().getAbsolutePath(),
"custom", "IRAO", "ZUKAN", dexSkinIndex, 25090, 0, true));
user.setDlcOverride("ZUKAN", player.getDexSkinFile());
} else {
dexSkinIndex = dlcList.getDlcIndex("IRAO", "ZUKAN", dexSkin);
user.removeDlcOverride("ZUKAN");
@ -314,6 +311,7 @@ public class PglHandler implements HttpHandler {
byte[] nameBytes = visitor.name().getBytes(StandardCharsets.UTF_16LE);
outputStream.write(nameBytes, 0, Math.min(14, nameBytes.length));
outputStream.writeBytes(-1, 14 - nameBytes.length);
outputStream.writeShort(0xFF); // Null terminator
// Full visitor type consists of a trainer class and what I call a 'personality' index
// that, along with the trainer class, determines which phrases the visitor uses.
@ -322,7 +320,6 @@ public class PglHandler implements HttpHandler {
// For example, if the visitor type is '0', then shop type '0' would be a raffle.
// However, if the visitor type is '2', then shop type '0' results in a dojo instead.
int visitorType = visitor.type().getClientId() + visitor.personality() * 8;
outputStream.writeShort(-1); // Does nothing, seems to be read as part of the name.
outputStream.write(visitorType);
outputStream.write(visitor.shopType().ordinal() + (7 - visitorType * 2 % 7));
outputStream.writeShort(0); // Does nothing

View File

@ -0,0 +1,55 @@
package entralinked.utility;
import java.nio.charset.StandardCharsets;
import org.bouncycastle.util.Arrays;
public class MysteryGiftUtility {
public static byte[] createUniversalGiftData4(byte[] bytes) {
// Check data size
if(bytes.length > 936) {
throw new IllegalArgumentException("Data too large: %s".formatted(bytes.length));
}
// TODO gift title & wonder card
byte[] result = new byte[936];
if(bytes.length <= 856) {
System.arraycopy(bytes, 0, result, 0x50, bytes.length);
} else {
System.arraycopy(bytes, 0, result, 0, bytes.length);
}
// Clear game version
result[0x48] = 0;
result[0x49] = 0;
return result;
}
public static byte[] createUniversalGiftData5(byte[] bytes) {
// Check data size
if(bytes.length > 720) {
throw new IllegalArgumentException("Data too large: %s".formatted(bytes.length));
}
byte[] result = new byte[720];
System.arraycopy(bytes, 0, result, 0, bytes.length);
result[0xCE] = 0; // Version flag
result[0x2CB] = 0; // Language code
// Create standard gift description if there is none
if(bytes.length == 204) {
Arrays.fill(result, 0xD0, 0x2CA, (byte)0xFF);
String description = "No description is available for this gift.";
byte[] descriptionBytes = description.replace('\n', '\uFFFE').getBytes(StandardCharsets.UTF_16LE);
System.arraycopy(descriptionBytes, 0, result, 0xD0, descriptionBytes.length);
}
// Recalculate checksum
int checksum = Crc16.calc(result, 0, 0x2CE);
result[0x2CE] = (byte)(checksum & 0xFF);
result[0x2CF] = (byte)((checksum >> 8) & 0xFF);
return result;
}
}