mirror of
https://github.com/kuroppoi/entralinked.git
synced 2026-09-08 08:45:14 -05:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df298d8703 | ||
|
|
556eab6b45 | ||
|
|
b831383284 | ||
|
|
b37f8b5624 | ||
|
|
abc32e5051 | ||
|
|
d5c2a57c51 |
@@ -6,9 +6,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
public record Configuration(
|
||||
String hostName,
|
||||
boolean clearPlayerDreamInfoOnWake,
|
||||
boolean allowOverwritingPlayerDreamInfo,
|
||||
boolean allowPlayerGameVersionMismatch,
|
||||
boolean allowWfcRegistrationThroughLogin) {
|
||||
|
||||
public static final Configuration DEFAULT = new Configuration("local", true, false, false, true);
|
||||
public static final Configuration DEFAULT = new Configuration("local", true, true);
|
||||
}
|
||||
|
||||
@@ -7,10 +7,15 @@ import java.awt.Dimension;
|
||||
import java.awt.Font;
|
||||
import java.awt.event.WindowAdapter;
|
||||
import java.awt.event.WindowEvent;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.URISyntaxException;
|
||||
import java.net.URL;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
import javax.swing.BorderFactory;
|
||||
@@ -19,6 +24,7 @@ import javax.swing.JFrame;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JMenu;
|
||||
import javax.swing.JMenuBar;
|
||||
import javax.swing.JOptionPane;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JScrollPane;
|
||||
import javax.swing.JTabbedPane;
|
||||
@@ -38,8 +44,14 @@ import com.formdev.flatlaf.intellijthemes.FlatOneDarkIJTheme;
|
||||
import com.formdev.flatlaf.util.ColorFunctions;
|
||||
|
||||
import entralinked.Entralinked;
|
||||
import entralinked.GameVersion;
|
||||
import entralinked.gui.FileChooser;
|
||||
import entralinked.gui.panels.DashboardPanel;
|
||||
import entralinked.model.player.Player;
|
||||
import entralinked.model.player.PlayerManager;
|
||||
import entralinked.utility.ConsumerAppender;
|
||||
import entralinked.utility.GsidUtility;
|
||||
import entralinked.utility.LEInputStream;
|
||||
import entralinked.utility.SwingUtility;
|
||||
|
||||
/**
|
||||
@@ -52,9 +64,13 @@ public class MainView {
|
||||
public static final Color TEXT_COLOR_ERROR = Color.RED.darker();
|
||||
private final StyleContext styleContext = StyleContext.getDefaultStyleContext();
|
||||
private final AttributeSet fontAttribute = styleContext.addAttribute(SimpleAttributeSet.EMPTY, StyleConstants.FontFamily, "Consolas");
|
||||
private final Entralinked entralinked;
|
||||
private final JFrame frame;
|
||||
private final JLabel statusLabel;
|
||||
|
||||
public MainView(Entralinked entralinked) {
|
||||
this.entralinked = entralinked;
|
||||
|
||||
// Set look and feel
|
||||
FlatOneDarkIJTheme.setup();
|
||||
UIManager.getDefaults().put("Component.focusedBorderColor", UIManager.get("Component.borderColor"));
|
||||
@@ -112,10 +128,15 @@ public class MainView {
|
||||
tabbedPane.addTab("Dashboard", new DashboardPanel(entralinked));
|
||||
|
||||
// Create window
|
||||
JFrame frame = new JFrame("Entralinked");
|
||||
frame = new JFrame("Entralinked");
|
||||
|
||||
// Create menu bar
|
||||
JMenuBar menuBar = new JMenuBar();
|
||||
JMenu toolsMenu = new JMenu("Tools");
|
||||
toolsMenu.add(SwingUtility.createAction("Import save file (Memory Link)", () -> FileChooser.showFileOpenDialog(frame, selection -> {
|
||||
importSaveFile(selection.file());
|
||||
})));
|
||||
menuBar.add(toolsMenu);
|
||||
JMenu helpMenu = new JMenu("Help");
|
||||
helpMenu.add(SwingUtility.createAction("Update PID (Error 60000)", () -> new PidToolDialog(entralinked, frame)));
|
||||
helpMenu.add(SwingUtility.createAction("GitHub", () -> {
|
||||
@@ -159,4 +180,77 @@ public class MainView {
|
||||
public void setStatusLabelText(String text) {
|
||||
statusLabel.setText(text);
|
||||
}
|
||||
|
||||
private void importSaveFile(File file) {
|
||||
// Check file size
|
||||
if(file.length() != 524288) {
|
||||
JOptionPane.showMessageDialog(frame, "Invalid file length.\n"
|
||||
+ "Expected 524288 bytes, got %s.".formatted(file.length()), "Attention", JOptionPane.WARNING_MESSAGE);
|
||||
return;
|
||||
}
|
||||
|
||||
PlayerManager playerManager = entralinked.getPlayerManager();
|
||||
Player player = null;
|
||||
GameVersion version = null;
|
||||
|
||||
try(LEInputStream inputStream = new LEInputStream(new FileInputStream(file))) {
|
||||
inputStream.skipNBytes(0x19400); // Skip to trainer info
|
||||
inputStream.skipNBytes(0x4);
|
||||
String name = inputStream.readUTF16(7);
|
||||
inputStream.skipNBytes(0x2);
|
||||
int trainerId = inputStream.readInt();
|
||||
int profileId = inputStream.readInt();
|
||||
inputStream.skipNBytes(0x2);
|
||||
int language = inputStream.read();
|
||||
int romCode = inputStream.read();
|
||||
version = GameVersion.lookup(romCode, language);
|
||||
|
||||
// Check game version
|
||||
if(version == null || version.isVersion2()) {
|
||||
JOptionPane.showMessageDialog(frame, "This is not a Black & White save file.", "Attention", JOptionPane.WARNING_MESSAGE);
|
||||
return;
|
||||
}
|
||||
|
||||
String gameSyncId = GsidUtility.stringifyGameSyncId(profileId == 0 ? Objects.hash(name, trainerId) & 0x7FFFFFFF : profileId);
|
||||
player = playerManager.doesPlayerExist(gameSyncId) ? playerManager.getPlayer(gameSyncId) : playerManager.registerPlayer(gameSyncId, version);
|
||||
} catch(Exception e) {
|
||||
SwingUtility.showExceptionInfo(frame, "Failed to read save data.", e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if player exists
|
||||
if(player == null) {
|
||||
JOptionPane.showMessageDialog(frame, "Failed to create player data.", "Attention", JOptionPane.WARNING_MESSAGE);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check version mismatch
|
||||
if(player.getGameVersion() != version) {
|
||||
if(!SwingUtility.showIgnorableConfirmDialog(frame,
|
||||
"The game version stored in the profile data does not match.\n"
|
||||
+ "Do you want to overwrite it and import the save file anyway?", "Attention")) {
|
||||
return;
|
||||
}
|
||||
|
||||
player.setGameVersion(version);
|
||||
|
||||
// Try to save player data
|
||||
if(!playerManager.savePlayer(player)) {
|
||||
JOptionPane.showMessageDialog(frame, "Failed to save player data.", "Attention", JOptionPane.WARNING_MESSAGE);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Copy save file
|
||||
try {
|
||||
Files.copy(file.toPath(), player.getSaveFile().toPath(), StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch(Exception e) {
|
||||
SwingUtility.showExceptionInfo(frame, "Failed to import save data.", e);
|
||||
return;
|
||||
}
|
||||
|
||||
JOptionPane.showMessageDialog(frame, "Save file has been imported successfully!\n"
|
||||
+ "You can now use Memory Link with the following Game Sync ID:\n\n%s".formatted(player.getGameSyncId()),
|
||||
"Attention", JOptionPane.INFORMATION_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
|
||||
import entralinked.GameVersion;
|
||||
import entralinked.utility.GsidUtility;
|
||||
|
||||
/**
|
||||
* Manager class for managing {@link Player} information (Global Link users)
|
||||
@@ -117,9 +118,14 @@ public class PlayerManager {
|
||||
Player player = mapper.readValue(inputFile, PlayerDto.class).toPlayer();
|
||||
String gameSyncId = player.getGameSyncId();
|
||||
|
||||
// Check if Game Sync ID is valid
|
||||
if(!GsidUtility.isValidGameSyncId(gameSyncId)) {
|
||||
throw new IOException("Invalid Game Sync ID: %s".formatted(gameSyncId));
|
||||
}
|
||||
|
||||
// Check for duplicate Game Sync ID
|
||||
if(doesPlayerExist(gameSyncId)) {
|
||||
throw new IOException("Duplicate Game Sync ID %s".formatted(gameSyncId));
|
||||
throw new IOException("Duplicate Game Sync ID: %s".formatted(gameSyncId));
|
||||
}
|
||||
|
||||
player.setDataDirectory(inputFile.getParentFile());
|
||||
@@ -180,7 +186,13 @@ public class PlayerManager {
|
||||
public Player registerPlayer(String gameSyncId, GameVersion version) {
|
||||
// Check for duplicate Game Sync ID
|
||||
if(playerMap.containsKey(gameSyncId)) {
|
||||
logger.warn("Attempted to register duplicate player {}", gameSyncId);
|
||||
logger.warn("Attempted to register duplicate Game Sync ID: {}", gameSyncId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if Game Sync ID is valid
|
||||
if(!GsidUtility.isValidGameSyncId(gameSyncId)) {
|
||||
logger.warn("Attempted to register invalid Game Sync ID: {}", gameSyncId);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -124,7 +124,7 @@ public class DlsHandler implements HttpHandler {
|
||||
*/
|
||||
private String getDlcGameCode(String gameCode) {
|
||||
return switch(gameCode) {
|
||||
case "IRAJ" -> "IRAO";
|
||||
case "IRAJ", "IRAK" -> "IRAO";
|
||||
default -> gameCode;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -321,6 +321,13 @@ public class PglHandler implements HttpHandler {
|
||||
*/
|
||||
private void handleMemoryLink(PglRequest request, Context ctx) throws IOException {
|
||||
LEOutputStream outputStream = new LEOutputStream(ctx.outputStream());
|
||||
|
||||
// Check if Game Sync ID is valid
|
||||
if(!GsidUtility.isValidGameSyncId(request.gameSyncId())) {
|
||||
writeStatusCode(outputStream, 8); // Invalid Game Sync ID
|
||||
return;
|
||||
}
|
||||
|
||||
Player player = playerManager.getPlayer(request.gameSyncId());
|
||||
User user = ctx.attribute("user");
|
||||
|
||||
@@ -413,11 +420,8 @@ public class PglHandler implements HttpHandler {
|
||||
LEOutputStream outputStream = new LEOutputStream(ctx.outputStream());
|
||||
Player player = playerManager.getPlayer(request.gameSyncId());
|
||||
|
||||
// Check if the player exists, has no Pokémon tucked in already and uses the same game version
|
||||
if(player == null
|
||||
|| (!configuration.allowOverwritingPlayerDreamInfo() && player.getStatus() != PlayerStatus.AWAKE)
|
||||
|| (!configuration.allowPlayerGameVersionMismatch() && player.getGameVersion() != null
|
||||
&& request.gameVersion() != player.getGameVersion())) {
|
||||
// Check if the player exists
|
||||
if(player == null) {
|
||||
// Skip everything
|
||||
ServletInputStream inputStream = ctx.req().getInputStream();
|
||||
|
||||
@@ -432,6 +436,18 @@ public class PglHandler implements HttpHandler {
|
||||
|
||||
logger.info("Player {} is uploading save data", player.getGameSyncId());
|
||||
|
||||
// Warn if player's current status is unexpected
|
||||
if(player.getStatus() != PlayerStatus.AWAKE)
|
||||
{
|
||||
logger.warn("Player {} is not AWAKE -- existing dream information will be overwritten!", player.getGameSyncId());
|
||||
}
|
||||
|
||||
// Warn if player's game version changed
|
||||
if(player.getGameVersion() != null && request.gameVersion() != player.getGameVersion())
|
||||
{
|
||||
logger.warn("Player {}'s game version changed from {} to {}", player.getGameSyncId(), player.getGameVersion(), request.gameVersion());
|
||||
}
|
||||
|
||||
// Try to store save data
|
||||
if(!playerManager.storePlayerGameSaveFile(player, ctx.bodyInputStream())) {
|
||||
writeStatusCode(outputStream, 4); // Game save data IO error
|
||||
@@ -472,9 +488,9 @@ public class PglHandler implements HttpHandler {
|
||||
// Prepare response
|
||||
LEOutputStream outputStream = new LEOutputStream(ctx.outputStream());
|
||||
|
||||
// Make sure Game Sync ID is present
|
||||
if(request.gameSyncId() == null) {
|
||||
writeStatusCode(outputStream, 1); // Unauthorized
|
||||
// Check if Game Sync ID is valid
|
||||
if(!GsidUtility.isValidGameSyncId(request.gameSyncId())) {
|
||||
writeStatusCode(outputStream, 8); // Invalid Game Sync ID
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -511,6 +527,13 @@ public class PglHandler implements HttpHandler {
|
||||
LEOutputStream outputStream = new LEOutputStream(ctx.outputStream());
|
||||
String gameSyncId = GsidUtility.stringifyGameSyncId(Integer.parseInt(ctx.body().replace("\u0000", ""))); // So quirky
|
||||
|
||||
// Check if Game Sync ID is valid
|
||||
if(!GsidUtility.isValidGameSyncId(gameSyncId)) {
|
||||
logger.debug("[account.createdata] Rejecting invalid Game Sync ID: {} ({})", gameSyncId, ctx.body());
|
||||
writeStatusCode(outputStream, 8); // Invalid Game Sync ID
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if player doesn't exist already
|
||||
if(playerManager.doesPlayerExist(gameSyncId)) {
|
||||
writeStatusCode(outputStream, 2); // Duplicate Game Sync ID
|
||||
|
||||
@@ -25,12 +25,6 @@ public class GsidDeserializer extends StdDeserializer<String> {
|
||||
|
||||
@Override
|
||||
public String deserialize(JsonParser parser, DeserializationContext context) throws IOException {
|
||||
int gsid = parser.getIntValue();
|
||||
|
||||
if(gsid < 0) {
|
||||
throw new IOException("Game Sync ID cannot be a negative number.");
|
||||
}
|
||||
|
||||
return GsidUtility.stringifyGameSyncId(gsid);
|
||||
return GsidUtility.stringifyGameSyncId(parser.getValueAsInt(-1));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,40 +2,87 @@ package entralinked.utility;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Game Sync ID generation process:
|
||||
*
|
||||
* Let's take example PID "1231499195".
|
||||
* Start by storing both the PID and its checksum (35497) in working variable "ugsid".
|
||||
* We do this by simply shifting the checksum 32 bits to the left: ugsid = pid | (checksum << 32) = 0x8AA949672FBB
|
||||
*
|
||||
* Calculating each character is pretty straightforward.
|
||||
* We just use the 5 least significant bits of ugsid as the index for the character table.
|
||||
* Character 1: 0x8AA949672FBB & 0x1F = 27 = '5'
|
||||
*
|
||||
* After each character, we shift ugsid 5 bits to the right. Since ugsid contains 48 bits of data,
|
||||
* taking the 5 least significant bits each time gives us enough indexes for (if we round up) exactly 10 characters.
|
||||
* If we take a look at the full value of ugsid (0x8AA949672FBB) in binary and split it into sections of 5 bits,
|
||||
* we'll actually already be able to see the entire Game Sync ID in reverse:
|
||||
*
|
||||
* Character: 'E' 'L' 'X' 'F' 'E' 'Y' 'Q' 'M' '7' '5'
|
||||
* Chartable index: 4 10 21 5 4 22 14 11 29 27
|
||||
* Binary: XX100 01010 10101 00101 00100 10110 01110 01011 11101 11011
|
||||
*
|
||||
* Adding all of the characters together gets us the Game Sync ID "57MQYEFXLE".
|
||||
*
|
||||
* Reversing this process to retrieve the PID and checksum is very straightforward.
|
||||
* Simply go through each character, find the index of it in the character table & left shift the total value 5 bits each time.
|
||||
* If we do this with the Game Sync ID we just created, then it should give us back the value of ugsid: 0x8AA949672FBB
|
||||
* To then retrieve the PID, simply do: ugsid & 0xFFFFFFFF = 1231499195
|
||||
* To retrieve the checksum, simply do: (ugsid >> 32) & 0xFFFF = 35497
|
||||
* We can then validate the Game Sync ID if we want to by comparing the checksums.
|
||||
*/
|
||||
public class GsidUtility {
|
||||
|
||||
public static final String GSID_CHARTABLE = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
public static final Pattern GSID_PATTERN = Pattern.compile("[A-HJ-NP-Z2-9]{10}");
|
||||
|
||||
/**
|
||||
* Stringifies the specified numerical Game Sync ID
|
||||
* Stringifies the specified numerical Game Sync ID.
|
||||
*
|
||||
* Black 2 - {@code sub_21B480C} (overlay #199)
|
||||
*/
|
||||
public static String stringifyGameSyncId(int gsid) {
|
||||
char[] output = new char[10];
|
||||
int index = 0;
|
||||
long checksum = Crc16.calc(gsid);
|
||||
long ugsid = gsid | (checksum << 32);
|
||||
|
||||
// v12 = gsid
|
||||
// v5 = sub_204405C(gsid, 4u)
|
||||
// v8 = v5 + __CFSHR__(v12, 31) + (v12 >> 31)
|
||||
|
||||
// uses unsigned ints for bitshift operations
|
||||
long ugsid = gsid;
|
||||
long checksum = Crc16.calc(gsid); // + __CFSHR__(v12, 31) + (v12 >> 31); ??
|
||||
|
||||
// do while v4 < 10
|
||||
for(int i = 0; i < output.length; i++) {
|
||||
index = (int)((ugsid & 0x1F) & 0x1FFFF); // chartable string is unicode, so normally multiplies by 2
|
||||
ugsid = (ugsid >> 5) | (checksum << 27);
|
||||
checksum >>= 5;
|
||||
output[i] = GSID_CHARTABLE.charAt(index); // sub_2048734(v4, chartable + index)
|
||||
int index = (int)((ugsid >> (5 * i)) & 0x1F);
|
||||
output[i] = GSID_CHARTABLE.charAt(index);
|
||||
}
|
||||
|
||||
return new String(output);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a Game Sync ID is valid by checking its length, characters & checksum.
|
||||
*
|
||||
* @return {@code true} if the Game Sync ID is valid, otherwise {@code false}.
|
||||
*/
|
||||
public static boolean isValidGameSyncId(String gsid) {
|
||||
return GSID_PATTERN.matcher(gsid).matches();
|
||||
if(gsid == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
int length = gsid.length();
|
||||
long ugsid = 0;
|
||||
|
||||
if(length != 10) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for(int i = 0; i < length; i++) {
|
||||
int index = GSID_CHARTABLE.indexOf(gsid.charAt(i));
|
||||
|
||||
if(index == -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
ugsid |= (long)index << (5 * i);
|
||||
}
|
||||
|
||||
int output = (int)(ugsid & 0xFFFFFFFF);
|
||||
int checksum = (int)((ugsid >> 32) & 0xFFFF);
|
||||
return output >= 0 && Crc16.calc(output) == checksum;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,4 +48,23 @@ public class LEInputStream extends FilterInputStream {
|
||||
public double readDouble() throws IOException {
|
||||
return Double.longBitsToDouble(readLong());
|
||||
}
|
||||
|
||||
public String readUTF16(int length) throws IOException {
|
||||
char[] charBuffer = new char[length];
|
||||
int read = 0;
|
||||
|
||||
for(int i = 0; i < charBuffer.length; i++) {
|
||||
int c = readShort() & 0xFFFF;
|
||||
|
||||
if(c == 0xFFFF) {
|
||||
break;
|
||||
}
|
||||
|
||||
charBuffer[i] = (char)c;
|
||||
read++;
|
||||
}
|
||||
|
||||
skipNBytes((length - (read + 1)) * 2);
|
||||
return new String(charBuffer, 0, read);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,20 @@ public class GsidUtilityTest {
|
||||
// Illegal length (should be 10)
|
||||
assertFalse(GsidUtility.isValidGameSyncId("Y67UEN38K"));
|
||||
assertFalse(GsidUtility.isValidGameSyncId("3ER5K8MBN4C"));
|
||||
|
||||
// Invalid checksum
|
||||
assertFalse(GsidUtility.isValidGameSyncId("VFWM2Q2ADH"));
|
||||
assertFalse(GsidUtility.isValidGameSyncId("44DAWDA4SH"));
|
||||
assertFalse(GsidUtility.isValidGameSyncId("J6F55U7FUE"));
|
||||
assertFalse(GsidUtility.isValidGameSyncId("8FAB4ZF6JF"));
|
||||
assertFalse(GsidUtility.isValidGameSyncId("HWLNS77HWD"));
|
||||
|
||||
// Negative PID
|
||||
assertFalse(GsidUtility.isValidGameSyncId("VYSBC78999"));
|
||||
assertFalse(GsidUtility.isValidGameSyncId("2UD7GJ8999"));
|
||||
assertFalse(GsidUtility.isValidGameSyncId("BTULWN8999"));
|
||||
assertFalse(GsidUtility.isValidGameSyncId("ZW3JBQ9999"));
|
||||
assertFalse(GsidUtility.isValidGameSyncId("MNTNWB9999"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -38,8 +52,8 @@ public class GsidUtilityTest {
|
||||
void testValidGameSyncIds() {
|
||||
assertTrue(GsidUtility.isValidGameSyncId("VFWM2QAXNF"));
|
||||
assertTrue(GsidUtility.isValidGameSyncId("44DAWDJKJ8"));
|
||||
assertTrue(GsidUtility.isValidGameSyncId("J6F55UB2X9"));
|
||||
assertTrue(GsidUtility.isValidGameSyncId("8FAB4Z3EN9"));
|
||||
assertTrue(GsidUtility.isValidGameSyncId("J6F55UB2XD"));
|
||||
assertTrue(GsidUtility.isValidGameSyncId("8FAB4Z3END"));
|
||||
assertTrue(GsidUtility.isValidGameSyncId("HWLNS7BTNB"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user