12 Commits

Author SHA1 Message Date
kuroppoi
556eab6b45 Remove unneeded status checks (#91)
Some checks failed
Build / dist (push) Has been cancelled
2026-04-15 02:09:14 +02:00
kuroppoi
b831383284 Fix oversight causing Korean versions to be unable to download skins 2026-04-15 01:55:07 +02:00
kuroppoi
b37f8b5624 Add menu option to import save files for Memory Link
Some checks failed
Build / dist (push) Has been cancelled
2025-04-15 01:19:17 +02:00
kuroppoi
abc32e5051 Add negative PID test case 2025-04-14 19:52:51 +02:00
kuroppoi
d5c2a57c51 Improve Game Sync ID processing
Also fixes an exception that occurs when a negative PID is sent through Memory Link.
2025-04-14 19:44:21 +02:00
kuroppoi
afc79c39ad Fix sprite shadow cutoff
Some checks failed
Build / dist (push) Has been cancelled
2025-04-09 22:11:19 +02:00
kuroppoi
15c42165ed Add utility function for creating styled labels 2025-04-09 22:09:43 +02:00
kuroppoi
e663d91559 Forgot this one 2025-04-09 21:56:30 +02:00
kuroppoi
4031d833e8 GUI tweaks + add option to edit décor (#6) 2025-04-09 21:45:52 +02:00
kuroppoi
2385398b4b Add encounter form locking
Some checks failed
Build / dist (push) Has been cancelled
2025-04-07 17:13:34 +02:00
kuroppoi
840d5e956a Reduce duplicate code in download function
Some checks are pending
Build / dist (push) Waiting to run
2025-04-07 04:46:48 +02:00
kuroppoi
bf2cc1f6bc Forgot some things
Some checks failed
Build / dist (push) Has been cancelled
2025-04-04 02:08:42 +02:00
26 changed files with 1048 additions and 502 deletions

View File

@@ -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);
}

View File

@@ -0,0 +1,46 @@
package entralinked.gui;
import java.awt.Toolkit;
import javax.swing.text.AttributeSet;
import javax.swing.text.BadLocationException;
import entralinked.utility.GsidUtility;
public class GsidDocumentFilter extends SizeLimitDocumentFilter {
public GsidDocumentFilter() {
super(10);
}
@Override
public void insertString(FilterBypass fb, int offset, String text, AttributeSet attrs) throws BadLocationException {
replace(fb, offset, 0, text, attrs);
}
@Override
public void replace(FilterBypass fb, int offset, int length, String text, AttributeSet attrs) throws BadLocationException {
if(text == null) {
return;
}
StringBuilder builder = new StringBuilder();
boolean shouldBeep = false;
for(int i = 0; i < text.length(); i++) {
char c = Character.toUpperCase(text.charAt(i));
if(GsidUtility.GSID_CHARTABLE.indexOf(c) != -1) {
builder.append(c);
} else {
shouldBeep = true;
}
}
if(shouldBeep) {
Toolkit.getDefaultToolkit().beep();
}
super.replace(fb, offset, length, builder.toString(), attrs);
}
}

View File

@@ -0,0 +1,40 @@
package entralinked.gui;
import java.awt.Toolkit;
import javax.swing.text.AttributeSet;
import javax.swing.text.BadLocationException;
import javax.swing.text.DocumentFilter;
public class SizeLimitDocumentFilter extends DocumentFilter {
private final int limit;
public SizeLimitDocumentFilter(int limit) {
this.limit = limit;
}
@Override
public void insertString(FilterBypass fb, int offset, String text, AttributeSet attrs) throws BadLocationException {
replace(fb, offset, 0, text, attrs);
}
@Override
public void replace(FilterBypass fb, int offset, int length, String text, AttributeSet attrs) throws BadLocationException {
if(text == null) {
return;
}
if(limit <= 0) {
super.replace(fb, offset, length, text, attrs);
return;
}
int finalLength = Math.min(text.length(), Math.max(0, limit - fb.getDocument().getLength() + length));
super.replace(fb, offset, length, text.substring(0, finalLength), attrs);
if(finalLength != text.length()) {
Toolkit.getDefaultToolkit().beep();
}
}
}

View File

@@ -9,6 +9,8 @@ import javax.swing.JComponent;
@SuppressWarnings("serial")
public class ShadowedSprite extends JComponent {
public static final int OFFSET_X = 4;
public static final int OFFSET_Y = 2;
private BufferedImage image;
private BufferedImage shadowImage;
private int scale;
@@ -28,9 +30,9 @@ public class ShadowedSprite extends JComponent {
Dimension size = getSize();
int width = image.getWidth() * scale;
int height = image.getHeight() * scale;
int x = size.width / 2 - width / 2;
int y = size.height / 2 - height / 2;
graphics.drawImage(shadowImage, x + 4, y + 2, width, height, null);
int x = size.width / 2 - width / 2 - OFFSET_X / 2;
int y = size.height / 2 - height / 2 - OFFSET_Y / 2;
graphics.drawImage(shadowImage, x + OFFSET_X, y + OFFSET_Y, width, height, null);
graphics.drawImage(image, x, y, width, height, null);
}
@@ -43,7 +45,6 @@ public class ShadowedSprite extends JComponent {
int height = image.getHeight();
shadowImage = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
// TODO shadow can be cut off if pixels touch the border
for(int i = 0; i < image.getWidth(); i++) {
for(int j = 0; j < image.getHeight(); j++) {
shadowImage.setRGB(i, j, image.getRGB(i, j) & 0x7F000000);
@@ -78,7 +79,7 @@ public class ShadowedSprite extends JComponent {
this.image = image;
this.scale = scale;
imageChanged();
updateSize(width, height);
updateSize(width + OFFSET_X, height + OFFSET_Y);
repaint();
}
}

View File

@@ -10,6 +10,7 @@ import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -162,9 +163,17 @@ public class DataManager {
.collect(Collectors.toCollection(ArrayList::new));
}
public static List<Integer> getMoveOptions(GameVersion gameVersion, PkmnSpecies species, PkmnGender gender) {
public static List<PkmnForm> getFormOptions(GameVersion gameVersion, PkmnSpecies species, PkmnGender gender) {
return getEncounters(gameVersion).stream()
.filter(x -> x.species() == species.id() && (!x.isGenderLocked() || species.gender() == gender))
.filter(x -> x.species() == species.id() && (!x.isGenderLocked() || x.gender() == gender))
.flatMap(x -> species.hasForms() ? Stream.of(species.forms()).filter(form -> x.hasForm(form.id())) : Stream.empty())
.distinct()
.collect(Collectors.toCollection(ArrayList::new));
}
public static List<Integer> getMoveOptions(GameVersion gameVersion, PkmnSpecies species, PkmnGender gender, int form) {
return getEncounters(gameVersion).stream()
.filter(x -> x.species() == species.id() && (!x.isGenderLocked() || x.gender() == gender) && x.hasForm(form))
.map(Encounter::moves)
.flatMap(List::stream)
.distinct()

View File

@@ -10,10 +10,21 @@ import entralinked.model.pkmn.PkmnGender;
public record Encounter(
@JsonProperty(required = true) int species,
@JsonProperty(required = true) List<Integer> moves,
PkmnGender gender, int versionMask) {
PkmnGender gender, int formMask, int versionMask) {
@JsonIgnore
public boolean isGenderLocked() {
return gender != null;
}
@JsonIgnore
public boolean isFormLocked() {
return formMask != 0;
}
@JsonIgnore
public boolean hasForm(int form) {
int bits = 1 << form;
return !isFormLocked() || (bits & formMask) == bits;
}
}

View File

@@ -0,0 +1,381 @@
package entralinked.gui.panels;
import java.awt.Dimension;
import java.awt.Image;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.Vector;
import javax.imageio.ImageIO;
import javax.swing.BorderFactory;
import javax.swing.DefaultComboBoxModel;
import javax.swing.ImageIcon;
import javax.swing.JButton;
import javax.swing.JComboBox;
import javax.swing.JLabel;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.filechooser.FileFilter;
import javax.swing.filechooser.FileNameExtensionFilter;
import entralinked.Entralinked;
import entralinked.GameVersion;
import entralinked.gui.FileChooser;
import entralinked.gui.ModelListCellRenderer;
import entralinked.model.player.Player;
import entralinked.utility.Crc16;
import entralinked.utility.LEOutputStream;
import entralinked.utility.SwingUtility;
import entralinked.utility.TiledImageUtility;
import net.miginfocom.swing.MigLayout;
@SuppressWarnings("serial")
public class CustomizationPanel extends JPanel {
@FunctionalInterface
private static interface SkinWriter {
public void writeSkin(OutputStream outputStream, BufferedImage image) throws IOException;
}
/**
* Internal model for combo boxes.
*/
private static record DlcOption(String type, String name, String path, boolean custom) {
public DlcOption(String type, String name, String path) {
this(type, name, path, false);
}
}
private static final FileFilter IMAGE_FILE_FILTER = new FileNameExtensionFilter("Image Files (*.png)", "png");
private static final FileFilter CGEAR_FILE_FILTER = new FileNameExtensionFilter("C-Gear Skin Files (*.bin, *.cgb, *.psk)", "bin", "cgb", "psk");
private static final FileFilter ZUKAN_FILE_FILTER = new FileNameExtensionFilter("Pokédex Skin Files (*.bin, *.pds)", "bin", "pds");
private static final byte[] NARC_HEADER = { 0x4E, 0x41, 0x52, 0x43, (byte)0xFE, (byte)0xFF, 0x00, 0x01 };
private static final Map<String, Image> skinCache = new HashMap<>();
private final Entralinked entralinked;
private final JComboBox<DlcOption> cgearComboBox;
private final JComboBox<DlcOption> zukanComboBox;
private final JComboBox<DlcOption> musicalComboBox;
private final JPanel optionPanel;
private Player player;
private GameVersion gameVersion;
private DlcOption customCGearSkin;
private DlcOption customDexSkin;
private DlcOption customMusical;
public CustomizationPanel(Entralinked entralinked) {
this.entralinked = entralinked;
setLayout(new MigLayout("align 50% 50%"));
// Create preview labels
JLabel cgearPreviewLabel = new JLabel("No preview available.", JLabel.CENTER);
cgearPreviewLabel.setPreferredSize(new Dimension(TiledImageUtility.SCREEN_WIDTH, TiledImageUtility.SCREEN_HEIGHT));
JLabel dexPreviewLabel = new JLabel("No preview available.", JLabel.CENTER);
dexPreviewLabel.setPreferredSize(new Dimension(TiledImageUtility.SCREEN_WIDTH, TiledImageUtility.SCREEN_HEIGHT));
// Create preview image panels
// Labels are added to a subpanel first otherwise the preferred size will include the border which causes issues
JPanel cgearPreviewPanel = new JPanel(new MigLayout("insets 0"));
cgearPreviewPanel.setBorder(BorderFactory.createTitledBorder("C-Gear Skin Preview"));
cgearPreviewPanel.add(cgearPreviewLabel);
JPanel dexPreviewPanel = new JPanel(new MigLayout("insets 0"));
dexPreviewPanel.setBorder(BorderFactory.createTitledBorder("Pokédex Skin Preview"));
dexPreviewPanel.add(dexPreviewLabel);
JPanel previewPanel = new JPanel();
previewPanel.add(cgearPreviewPanel);
previewPanel.add(dexPreviewPanel);
add(previewPanel, "spanx, align 50%, wrap");
// Create combo boxes
ModelListCellRenderer<DlcOption> renderer = new ModelListCellRenderer<>(DlcOption.class, DlcOption::name, "Do not change");
cgearComboBox = new JComboBox<>();
cgearComboBox.setMinimumSize(cgearComboBox.getPreferredSize());
cgearComboBox.setRenderer(renderer);
cgearComboBox.addActionListener(event -> updateSkinPreview(cgearComboBox, cgearPreviewLabel));
zukanComboBox = new JComboBox<>();
zukanComboBox.setMinimumSize(zukanComboBox.getPreferredSize());
zukanComboBox.setRenderer(renderer);
zukanComboBox.addActionListener(event -> updateSkinPreview(zukanComboBox, dexPreviewLabel));
musicalComboBox = new JComboBox<>();
musicalComboBox.setMinimumSize(musicalComboBox.getPreferredSize());
musicalComboBox.setRenderer(renderer);
// Create option panel
optionPanel = new JPanel(new MigLayout());
// Create C-Gear skin selector
createDlcOption("C-Gear Skin", cgearComboBox, () -> {
FileChooser.showFileOpenDialog(getRootPane(), Arrays.asList(IMAGE_FILE_FILTER, CGEAR_FILE_FILTER), selection -> {
File dst = player.getCGearSkinFile();
File file = selection.file();
FileFilter filter = selection.filter();
if(filter == IMAGE_FILE_FILTER) {
if(!importSkinImage(file, dst, (stream, image) -> TiledImageUtility.writeCGearSkin(stream, image, !gameVersion.isVersion2()))) {
return;
}
} else if(filter == CGEAR_FILE_FILTER) {
if(!importSkinFile(file, dst, 9730)) {
return;
}
} else {
return;
}
DlcOption option = new DlcOption(gameVersion.isVersion2() ? "CGEAR2" : "CGEAR", file.getName(), dst.getAbsolutePath(), true);
updateCustomOption(cgearComboBox, customCGearSkin, option);
customCGearSkin = option;
player.setCustomCGearSkin(customCGearSkin.name());
});
});
// Create Pokédex skin selector
createDlcOption("Pokédex Skin", zukanComboBox, () -> {
FileChooser.showFileOpenDialog(getRootPane(), Arrays.asList(IMAGE_FILE_FILTER, ZUKAN_FILE_FILTER), selection -> {
File dst = player.getDexSkinFile();
File file = selection.file();
FileFilter filter = selection.filter();
if(filter == IMAGE_FILE_FILTER) {
if(!importSkinImage(file, dst, (stream, image) -> TiledImageUtility.writeDexSkin(stream, image, TiledImageUtility.generateBackgroundColors(image)))) {
return;
}
} else if(filter == ZUKAN_FILE_FILTER) {
if(!importSkinFile(file, dst, 25090)) {
return;
}
} else {
return;
}
DlcOption option = new DlcOption("ZUKAN", file.getName(), dst.getAbsolutePath(), true);
updateCustomOption(zukanComboBox, customDexSkin, option);
customDexSkin = option;
player.setCustomDexSkin(customDexSkin.name());
});
});
// Create musical show selector
createDlcOption("Musical Show", musicalComboBox, () -> {
SwingUtility.showIgnorableHint(getRootPane(), "Please exercise caution when importing custom musicals.\n"
+ "Downloading invalid data might cause game crashes or other issues.", "Attention", JOptionPane.WARNING_MESSAGE);
FileChooser.showFileOpenDialog(getRootPane(), selection -> {
File dst = player.getMusicalFile();
File file = selection.file();
if(!importNarcFile(file, dst)) {
return;
}
DlcOption option = new DlcOption("MUSICAL", file.getName(), dst.getAbsolutePath(), true);
updateCustomOption(musicalComboBox, customMusical, option);
customMusical = option;
player.setCustomMusical(customMusical.name());
});
});
add(optionPanel, "spanx, align 50%");
}
public void loadProfile(Player player) {
this.player = player;
gameVersion = player.getGameVersion();
String cgearType = player.getGameVersion().isVersion2() ? "CGEAR2" : "CGEAR";
customCGearSkin = player.getCustomCGearSkin() == null ? null : new DlcOption(cgearType, player.getCustomCGearSkin(), player.getCGearSkinFile().getAbsolutePath(), true);
customDexSkin = player.getCustomDexSkin() == null ? null : new DlcOption("ZUKAN", player.getCustomDexSkin(), player.getDexSkinFile().getAbsolutePath(), true);
customMusical = player.getCustomMusical() == null ? null : new DlcOption("MUSICAL", player.getCustomMusical(), player.getMusicalFile().getAbsolutePath(), true);
updateDlcOptions(cgearComboBox, cgearType, player.getCGearSkin(), customCGearSkin);
updateDlcOptions(zukanComboBox, "ZUKAN", player.getDexSkin(), customDexSkin);
updateDlcOptions(musicalComboBox, "MUSICAL", player.getMusical(), customMusical);
}
public void saveProfile(Player player) {
DlcOption cgearSkin = (DlcOption)cgearComboBox.getSelectedItem();
DlcOption dexSkin = (DlcOption)zukanComboBox.getSelectedItem();
DlcOption musical = (DlcOption)musicalComboBox.getSelectedItem();
player.setCGearSkin(cgearSkin == null ? null : cgearSkin.custom() ? "custom" : cgearSkin.name());
player.setDexSkin(dexSkin == null ? null : dexSkin.custom() ? "custom" : dexSkin.name());
player.setMusical(musical == null ? null : musical.custom() ? "custom" : musical.name());
}
private void createDlcOption(String label, JComboBox<DlcOption> comboBox, Runnable importListener) {
optionPanel.add(new JLabel(label), "sizegroup label");
optionPanel.add(comboBox, "sizegroup option");
JButton importButton = new JButton("Import");
importButton.addActionListener(event -> importListener.run());
optionPanel.add(importButton, "wrap");
}
private void updateDlcOptions(JComboBox<DlcOption> comboBox, String type, String selectedOption, DlcOption customOption) {
Vector<DlcOption> options = new Vector<>();
if(customOption != null) {
options.add(customOption);
}
entralinked.getDlcList().getDlcList("IRAO", type).forEach(dlc -> options.add(new DlcOption(type, dlc.name(), dlc.path())));
DlcOption selection = selectedOption == null ? null : selectedOption.equals("custom") ? customOption : options.stream().filter(x -> selectedOption.equals(x.name())).findFirst().orElse(null);
options.add(0, null); // "Do not change" option
comboBox.setModel(new DefaultComboBoxModel<DlcOption>(options));
comboBox.setSelectedItem(selection);
}
private void updateCustomOption(JComboBox<DlcOption> comboBox, DlcOption oldValue, DlcOption newValue) {
DefaultComboBoxModel<DlcOption> model = (DefaultComboBoxModel<DlcOption>)comboBox.getModel();
if(oldValue != null) {
model.removeElement(oldValue);
skinCache.remove(oldValue.path());
}
model.insertElementAt(newValue, 1);
model.setSelectedItem(newValue);
}
private void updateSkinPreview(JComboBox<DlcOption> comboBox, JLabel previewLabel) {
Image preview = getSkinImage((DlcOption)comboBox.getSelectedItem());
previewLabel.setText(preview == null ? "No preview available." : "");
previewLabel.setIcon(preview == null ? null : new ImageIcon(preview));
}
private Image getSkinImage(DlcOption option) {
return option == null ? null : skinCache.computeIfAbsent(option.path(), path -> {
try(FileInputStream inputStream = new FileInputStream(path)) {
return switch(option.type()) {
case "CGEAR" -> TiledImageUtility.readCGearSkin(inputStream, true);
case "CGEAR2" -> TiledImageUtility.readCGearSkin(inputStream, false);
case "ZUKAN" -> TiledImageUtility.readDexSkin(inputStream, true);
default -> throw new IllegalArgumentException("Invalid type: " + option.type());
};
} catch(Exception e) {
SwingUtility.showExceptionInfo(getRootPane(), "Failed to load skin preview.", e);
return null;
}
});
}
private boolean importSkinFile(File src, File dst, int expectedSize) {
int sizeWithoutChecksum = expectedSize - 2;
int length = (int)src.length();
// Check content length
if(length != expectedSize && length != sizeWithoutChecksum) {
JOptionPane.showMessageDialog(getRootPane(), "Invalid content length, expected either %s or %s bytes."
.formatted(sizeWithoutChecksum, expectedSize), "Attention", JOptionPane.WARNING_MESSAGE);
return false;
}
try {
byte[] bytes = Files.readAllBytes(src.toPath());
boolean writeChecksum = true;
// Validate checksum
if(length == expectedSize) {
int checksum = Crc16.calc(bytes, 0, sizeWithoutChecksum);
int checksumInFile = (bytes[bytes.length - 2] & 0xFF) | ((bytes[bytes.length - 1] & 0xFF) << 8);
if(checksum != checksumInFile) {
JOptionPane.showMessageDialog(getRootPane(), "File checksum doesn't match.", "Attention", JOptionPane.WARNING_MESSAGE);
return false;
}
writeChecksum = false;
}
// Write to destination & append checksum if necessary
try(LEOutputStream outputStream = new LEOutputStream(new FileOutputStream(dst))) {
outputStream.write(bytes);
if(writeChecksum) {
outputStream.writeShort(Crc16.calc(bytes));
}
}
return true;
} catch(Exception e) {
SwingUtility.showExceptionInfo(getRootPane(), "Failed to import skin.", e);
}
return false;
}
private boolean importSkinImage(File src, File dst, SkinWriter writer) {
try {
BufferedImage image = ImageIO.read(src);
int width = TiledImageUtility.SCREEN_WIDTH;
int height = TiledImageUtility.SCREEN_HEIGHT;
if(image.getWidth() != width || image.getHeight() != height) {
JOptionPane.showMessageDialog(getRootPane(), "Image size must be %sx%s pixels.".formatted(width, height), "Attention", JOptionPane.WARNING_MESSAGE);
return false;
}
ByteArrayOutputStream byteStream = new ByteArrayOutputStream();
writer.writeSkin(byteStream, image);
byte[] bytes = byteStream.toByteArray();
try(LEOutputStream outputStream = new LEOutputStream(new FileOutputStream(dst))) {
outputStream.write(bytes);
outputStream.writeShort(Crc16.calc(bytes));
}
return true;
} catch(IllegalArgumentException e) {
JOptionPane.showMessageDialog(getRootPane(), e.getMessage(), "Attention", JOptionPane.WARNING_MESSAGE);
} catch(Exception e) {
SwingUtility.showExceptionInfo(getRootPane(), "Failed to import skin image.", e);
}
return false;
}
private boolean importNarcFile(File src, File dst) {
try {
byte[] bytes = Files.readAllBytes(src.toPath());
int offset = 0;
// Check narc header
if(!Arrays.equals(bytes, 0, NARC_HEADER.length, NARC_HEADER, 0, NARC_HEADER.length)) {
if(bytes.length < 16 || !Arrays.equals(bytes, 16, 16 + NARC_HEADER.length, NARC_HEADER, 0, NARC_HEADER.length)) {
JOptionPane.showMessageDialog(getRootPane(), "Invalid or unsupported file.", "Attention", JOptionPane.WARNING_MESSAGE);
return false;
}
offset = 16;
}
// TODO utility function
int length = ((bytes[offset + NARC_HEADER.length + 3] & 0xFF) << 24)
| ((bytes[offset + NARC_HEADER.length + 2] & 0xFF) << 16)
| ((bytes[offset + NARC_HEADER.length + 1] & 0xFF) << 8)
| (bytes[offset + NARC_HEADER.length] & 0xFF);
if(offset + length >= bytes.length) {
JOptionPane.showMessageDialog(getRootPane(), "File data is malformed or corrupt.", "Attention", JOptionPane.WARNING_MESSAGE);
return false;
}
// Write to destination
try(LEOutputStream outputStream = new LEOutputStream(new FileOutputStream(dst))) {
outputStream.write(bytes, offset, length);
outputStream.writeShort(Crc16.calc(bytes, offset, length));
}
return true;
} catch(Exception e) {
SwingUtility.showExceptionInfo(getRootPane(), "Failed to import NARC file.", e);
}
return false;
}
}

View File

@@ -13,14 +13,15 @@ import javax.swing.JLabel;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JTabbedPane;
import javax.swing.text.AbstractDocument;
import com.formdev.flatlaf.FlatClientProperties;
import com.formdev.flatlaf.extras.components.FlatTabbedPane;
import com.formdev.flatlaf.extras.components.FlatTabbedPane.TabAreaAlignment;
import com.formdev.flatlaf.extras.components.FlatTextField;
import entralinked.Entralinked;
import entralinked.GameVersion;
import entralinked.gui.GsidDocumentFilter;
import entralinked.gui.data.DataManager;
import entralinked.model.player.Player;
import entralinked.model.player.PlayerStatus;
@@ -41,6 +42,7 @@ public class DashboardPanel extends JPanel {
private EncounterEditorPanel encounterPanel;
private ItemEditorPanel itemPanel;
private VisitorEditorPanel visitorPanel;
private CustomizationPanel customizePanel;
private MiscPanel miscPanel;
private Player player;
private boolean initialized;
@@ -48,16 +50,10 @@ public class DashboardPanel extends JPanel {
public DashboardPanel(Entralinked entralinked) {
this.entralinked = entralinked;
// Create login labels
JLabel loginTitle = new JLabel("Log in");
loginTitle.putClientProperty(FlatClientProperties.STYLE, "font:bold +8");
JLabel loginDescription = new JLabel("<html>Tuck in a Pokémon and enter your Game Sync ID to continue.<br/>"
+ "Your Game Sync ID can be found in 'Game Sync Settings' in the game's main menu.</html>");
loginDescription.putClientProperty(FlatClientProperties.STYLE, "[dark]foreground:darken(@foreground,20%)");
// Create GSID field
FlatTextField gsidTextField = new FlatTextField();
gsidTextField.setPlaceholderText("XXXXXXXXXX");
((AbstractDocument)gsidTextField.getDocument()).setDocumentFilter(new GsidDocumentFilter());
// Create login button
JButton loginButton = new JButton("Log in");
@@ -74,8 +70,14 @@ public class DashboardPanel extends JPanel {
// Create login panel
JPanel loginPanel = new JPanel(new MigLayout("wrap, align 50% 50%"));
loginPanel.add(loginTitle);
loginPanel.add(loginDescription);
String loginDescription = """
<html>
Tuck in a Pokémon and enter your Game Sync ID to continue.<br/>
Your Game Sync ID can be found in 'Game Sync Settings' in the game's main menu.
</html>
""";
loginPanel.add(SwingUtility.createTitleLabel("Log in"));
loginPanel.add(SwingUtility.createDescriptionLabel(loginDescription));
loginPanel.add(new JLabel("Game Sync ID"), "gapy 8");
loginPanel.add(gsidTextField, "growx");
loginPanel.add(loginButton, "growx");
@@ -90,6 +92,7 @@ public class DashboardPanel extends JPanel {
encounterPanel.saveProfile(player);
itemPanel.saveProfile(player);
visitorPanel.saveProfile(player);
customizePanel.saveProfile(player);
miscPanel.saveProfile(player);
player.setStatus(PlayerStatus.WAKE_READY);
@@ -177,6 +180,7 @@ public class DashboardPanel extends JPanel {
encounterPanel.loadProfile(player);
itemPanel.loadProfile(player);
visitorPanel.loadProfile(player);
customizePanel.loadProfile(player);
miscPanel.loadProfile(player);
} catch(Exception e) {
SwingUtility.showExceptionInfo(getRootPane(), "Failed to load player data.", e);
@@ -195,7 +199,8 @@ public class DashboardPanel extends JPanel {
encounterPanel = new EncounterEditorPanel();
itemPanel = new ItemEditorPanel();
visitorPanel = new VisitorEditorPanel();
miscPanel = new MiscPanel(entralinked);
customizePanel = new CustomizationPanel(entralinked);
miscPanel = new MiscPanel();
} catch(Exception e) {
SwingUtility.showExceptionInfo(getRootPane(), "Failed to initialize dashboard.", e);
return false;
@@ -205,6 +210,7 @@ public class DashboardPanel extends JPanel {
tabbedPane.add("Entree Forest", encounterPanel);
tabbedPane.add("Dream Remnants", itemPanel);
tabbedPane.add("Join Avenue", visitorPanel);
tabbedPane.add("Customization", customizePanel);
tabbedPane.add("Miscellaneous", miscPanel);
initialized = true;
return true;

View File

@@ -62,14 +62,28 @@ public class EncounterEditorPanel extends TableEditorPanel {
table.enableOption(row, MOVE_COLUMN);
table.enableOption(row, FORM_COLUMN);
table.enableOption(row, ANIMATION_COLUMN);
PkmnSpecies species = (PkmnSpecies)newValue;
updateGenderOptions(row, species);
updateFormOptions(row, species);
updateMoveOptions(row, species);
updateGenderOptions(row);
updateFormOptions(row);
updateMoveOptions(row);
optionLock = false;
} else if(column == GENDER_COLUMN) {
if(!isLegalMode()) {
return; // Gender only affects move options if legal mode is enabled
return; // Gender only affects form options if legal mode is enabled
}
if(newValue == null) {
table.disableOption(row, FORM_COLUMN);
return;
}
updateFormOptions(row);
if(oldValue == null) {
table.enableOption(row, FORM_COLUMN);
}
} else if(column == FORM_COLUMN) {
if(!isLegalMode()) {
return; // Form only affects move options if legal mode is enabled
}
if(newValue == null) {
@@ -77,7 +91,7 @@ public class EncounterEditorPanel extends TableEditorPanel {
return;
}
updateMoveOptions(row, getSpecies(row));
updateMoveOptions(row);
if(oldValue == null) {
table.enableOption(row, MOVE_COLUMN);
@@ -93,9 +107,9 @@ public class EncounterEditorPanel extends TableEditorPanel {
if(species != null) {
optionLock = true;
updateGenderOptions(i, species);
updateFormOptions(i, species);
updateMoveOptions(i, species);
updateGenderOptions(i);
updateFormOptions(i);
updateMoveOptions(i);
optionLock = false;
}
}
@@ -137,18 +151,21 @@ public class EncounterEditorPanel extends TableEditorPanel {
(a, b) -> a.name().compareTo(b.name()), true);
}
private void updateMoveOptions(int row, PkmnSpecies species) {
setOptions(row, MOVE_COLUMN,
isLegalMode() ? DataManager.getMoveOptions(gameVersion, species, getGender(row)) : DataManager.getMoveIds(),
(a, b) -> DataManager.getMoveName(a).compareTo(DataManager.getMoveName(b)), true);
}
private void updateGenderOptions(int row, PkmnSpecies species) {
private void updateGenderOptions(int row) {
PkmnSpecies species = getSpecies(row);
setOptions(row, GENDER_COLUMN, isLegalMode() ? DataManager.getGenderOptions(gameVersion, species) : species.getGenders());
}
private void updateFormOptions(int row, PkmnSpecies species) {
setOptions(row, FORM_COLUMN, species.hasForms() ? Arrays.asList(species.forms()) : Collections.emptyList());
private void updateFormOptions(int row) {
PkmnSpecies species = getSpecies(row);
setOptions(row, FORM_COLUMN, species.hasForms() ? (isLegalMode() ? DataManager.getFormOptions(gameVersion, species, getGender(row))
: Arrays.asList(species.forms())) : Collections.emptyList());
}
private void updateMoveOptions(int row) {
setOptions(row, MOVE_COLUMN,
isLegalMode() ? DataManager.getMoveOptions(gameVersion, getSpecies(row), getGender(row), getForm(row)) : DataManager.getMoveIds(),
(a, b) -> DataManager.getMoveName(a).compareTo(DataManager.getMoveName(b)), true);
}
public void loadProfile(Player player) {

View File

@@ -1,380 +1,152 @@
package entralinked.gui.panels;
import java.awt.Image;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.Vector;
import java.util.ArrayList;
import java.util.List;
import javax.imageio.ImageIO;
import javax.swing.BorderFactory;
import javax.swing.DefaultComboBoxModel;
import javax.swing.ImageIcon;
import javax.swing.JButton;
import javax.swing.JComboBox;
import javax.swing.JLabel;
import javax.swing.JOptionPane;
import javax.swing.JCheckBox;
import javax.swing.JPanel;
import javax.swing.JSpinner;
import javax.swing.JTextField;
import javax.swing.SpinnerNumberModel;
import javax.swing.filechooser.FileFilter;
import javax.swing.filechooser.FileNameExtensionFilter;
import javax.swing.text.PlainDocument;
import entralinked.Entralinked;
import entralinked.GameVersion;
import entralinked.gui.FileChooser;
import entralinked.gui.ModelListCellRenderer;
import entralinked.gui.SizeLimitDocumentFilter;
import entralinked.model.player.DreamDecor;
import entralinked.model.player.Player;
import entralinked.utility.Crc16;
import entralinked.utility.LEOutputStream;
import entralinked.utility.SwingUtility;
import entralinked.utility.TiledImageUtility;
import net.miginfocom.swing.MigLayout;
@SuppressWarnings("serial")
public class MiscPanel extends JPanel {
@FunctionalInterface
private static interface SkinWriter {
public void writeSkin(OutputStream outputStream, BufferedImage image) throws IOException;
}
/**
* Internal model for combo boxes.
* Container for keeping track of Décor options.
*/
private static record DlcOption(String type, String name, String path, boolean custom) {
private static class DecorOption {
public DlcOption(String type, String name, String path) {
this(type, name, path, false);
private final JCheckBox checkBox;
private final JSpinner spinner;
private final JTextField nameField;
public DecorOption(JPanel parent, String label) {
spinner = new JSpinner(new SpinnerNumberModel(0, 0, 127, 1));
nameField = new JTextField();
((PlainDocument)nameField.getDocument()).setDocumentFilter(new SizeLimitDocumentFilter(12));
checkBox = new JCheckBox(label);
checkBox.addActionListener(event -> {
boolean active = checkBox.isSelected();
spinner.setEnabled(active);
nameField.setEnabled(active);
});
parent.add(checkBox);
parent.add(spinner);
parent.add(nameField, "growx");
setActive(true);
}
public void setActive(boolean active) {
checkBox.setSelected(active);
spinner.setEnabled(active);
nameField.setEnabled(active);
}
public boolean isActive() {
return checkBox.isSelected();
}
public void setId(int id) {
spinner.setValue(id);
}
public int getId() {
return (int)spinner.getValue();
}
public void setName(String name) {
nameField.setText(name);
}
public String getName() {
return nameField.getText();
}
}
private static final FileFilter IMAGE_FILE_FILTER = new FileNameExtensionFilter("Image Files (*.png)", "png");
private static final FileFilter CGEAR_FILE_FILTER = new FileNameExtensionFilter("C-Gear Skin Files (*.bin, *.cgb, *.psk)", "bin", "cgb", "psk");
private static final FileFilter ZUKAN_FILE_FILTER = new FileNameExtensionFilter("Pokédex Skin Files (*.bin, *.pds)", "bin", "pds");
private static final byte[] NARC_HEADER = { 0x4E, 0x41, 0x52, 0x43, (byte)0xFE, (byte)0xFF, 0x00, 0x01 };
private static final BufferedImage EMPTY_IMAGE = new BufferedImage(TiledImageUtility.SCREEN_WIDTH, TiledImageUtility.SCREEN_HEIGHT, BufferedImage.TYPE_INT_RGB);
private static final Map<String, Image> skinCache = new HashMap<>();
private final Entralinked entralinked;
private final JComboBox<DlcOption> cgearComboBox;
private final JComboBox<DlcOption> zukanComboBox;
private final JComboBox<DlcOption> musicalComboBox;
private final JPanel optionPanel;
public static final int DECOR_COUNT = 5;
private final List<DecorOption> decorOptions = new ArrayList<>();
private final JSpinner levelSpinner;
private Player player;
private GameVersion gameVersion;
private DlcOption customCGearSkin;
private DlcOption customDexSkin;
private DlcOption customMusical;
public MiscPanel() {
setLayout(new MigLayout("align 50% 50%, insets 0, wrap", "", "[]0[]"));
// Create decor panel
JPanel decorPanel = new JPanel(new MigLayout("insets 0, wrap 3, fill", "[][][grow]"));
String decorDescription = """
<html>
Configure Décor options to appear in Loblolly's studio.<br/>
Due to the closure of the Dream World, this function serves no real purpose<br/>
and is mostly meant for people who want to test or just play around with it.
</html>
""";
decorPanel.add(SwingUtility.createTitleLabel("Dream Décor"), "spanx, wrap");
decorPanel.add(SwingUtility.createDescriptionLabel(decorDescription), "spanx, wrap");
public MiscPanel(Entralinked entralinked) {
this.entralinked = entralinked;
setLayout(new MigLayout("align 50% 50%"));
for(int i = 0; i < DECOR_COUNT; i++) {
decorOptions.add(new DecorOption(decorPanel, "Decor %s".formatted(i + 1)));
}
// Create preview labels
JLabel cgearPreviewLabel = new JLabel("", JLabel.CENTER);
cgearPreviewLabel.setBorder(BorderFactory.createTitledBorder("C-Gear Skin Preview"));
JLabel dexPreviewLabel = new JLabel("", JLabel.CENTER);
dexPreviewLabel.setBorder(BorderFactory.createTitledBorder("Pokédex Skin Preview"));
// Create preview image panel
JPanel previewPanel = new JPanel();
previewPanel.add(cgearPreviewLabel);
previewPanel.add(dexPreviewLabel);
add(previewPanel, "spanx, align 50%, wrap");
// Create combo boxes
ModelListCellRenderer<DlcOption> renderer = new ModelListCellRenderer<>(DlcOption.class, DlcOption::name, "Do not change");
cgearComboBox = new JComboBox<>();
cgearComboBox.setMinimumSize(cgearComboBox.getPreferredSize());
cgearComboBox.setRenderer(renderer);
cgearComboBox.addActionListener(event -> {
cgearPreviewLabel.setIcon(new ImageIcon(getSkinImage((DlcOption)cgearComboBox.getSelectedItem())));
});
zukanComboBox = new JComboBox<>();
zukanComboBox.setMinimumSize(zukanComboBox.getPreferredSize());
zukanComboBox.setRenderer(renderer);
zukanComboBox.addActionListener(event -> {
dexPreviewLabel.setIcon(new ImageIcon(getSkinImage((DlcOption)zukanComboBox.getSelectedItem())));
});
musicalComboBox = new JComboBox<>();
musicalComboBox.setMinimumSize(musicalComboBox.getPreferredSize());
musicalComboBox.setRenderer(renderer);
// Create option panel
optionPanel = new JPanel(new MigLayout());
// Create C-Gear skin selector
createDlcOption("C-Gear Skin", cgearComboBox, () -> {
FileChooser.showFileOpenDialog(getRootPane(), Arrays.asList(IMAGE_FILE_FILTER, CGEAR_FILE_FILTER), selection -> {
File dst = player.getCGearSkinFile();
File file = selection.file();
FileFilter filter = selection.filter();
if(filter == IMAGE_FILE_FILTER) {
if(!importSkinImage(file, dst, (stream, image) -> TiledImageUtility.writeCGearSkin(stream, image, !gameVersion.isVersion2()))) {
return;
}
} else if(filter == CGEAR_FILE_FILTER) {
if(!importSkinFile(file, dst, 9730)) {
return;
}
} else {
return;
}
DlcOption option = new DlcOption(gameVersion.isVersion2() ? "CGEAR2" : "CGEAR", file.getName(), dst.getAbsolutePath(), true);
updateCustomOption(cgearComboBox, customCGearSkin, option);
customCGearSkin = option;
player.setCustomCGearSkin(customCGearSkin.name());
});
// Create decor option buttons
JButton resetButton = new JButton("Default");
resetButton.addActionListener(event -> {
for(int i = 0; i < DECOR_COUNT; i++) {
DecorOption option = decorOptions.get(i);
DreamDecor decor = DreamDecor.DEFAULT_DECOR.get(i);
option.setActive(true);
option.setId(decor.id());
option.setName(decor.name());
}
});
// Create Pokédex skin selector
createDlcOption("Pokédex Skin", zukanComboBox, () -> {
FileChooser.showFileOpenDialog(getRootPane(), Arrays.asList(IMAGE_FILE_FILTER, ZUKAN_FILE_FILTER), selection -> {
File dst = player.getDexSkinFile();
File file = selection.file();
FileFilter filter = selection.filter();
if(filter == IMAGE_FILE_FILTER) {
if(!importSkinImage(file, dst, (stream, image) -> TiledImageUtility.writeDexSkin(stream, image, TiledImageUtility.generateBackgroundColors(image)))) {
return;
}
} else if(filter == ZUKAN_FILE_FILTER) {
if(!importSkinFile(file, dst, 25090)) {
return;
}
} else {
return;
}
DlcOption option = new DlcOption("ZUKAN", file.getName(), dst.getAbsolutePath(), true);
updateCustomOption(zukanComboBox, customDexSkin, option);
customDexSkin = option;
player.setCustomDexSkin(customDexSkin.name());
});
JButton clearButton = new JButton("Clear");
clearButton.addActionListener(event -> {
for(DecorOption option : decorOptions) {
option.setActive(false);
option.setId(0);
option.setName("");
}
});
// Create musical show selector
createDlcOption("Musical Show", musicalComboBox, () -> {
SwingUtility.showIgnorableHint(getRootPane(), "Please exercise caution when importing custom musicals.\n"
+ "Downloading invalid data might cause game crashes or other issues.", "Attention", JOptionPane.WARNING_MESSAGE);
FileChooser.showFileOpenDialog(getRootPane(), selection -> {
File dst = player.getMusicalFile();
File file = selection.file();
if(!importNarcFile(file, dst)) {
return;
}
DlcOption option = new DlcOption("MUSICAL", file.getName(), dst.getAbsolutePath(), true);
updateCustomOption(musicalComboBox, customMusical, option);
customMusical = option;
player.setCustomMusical(customMusical.name());
});
});
// Create decor button panel
JPanel buttonPanel = new JPanel(new MigLayout("insets 0", "0[]"));
buttonPanel.add(resetButton);
buttonPanel.add(clearButton);
decorPanel.add(buttonPanel, "spanx, align right");
add(decorPanel, "spanx, growx");
// Create level spinner
levelSpinner = new JSpinner(new SpinnerNumberModel(0, 0, 99, 1)) ;
optionPanel.add(new JLabel("Level Gain"), "sizegroup label");
optionPanel.add(levelSpinner, "sizegroup option");
add(optionPanel, "spanx, align 50%");
// Create level panel
levelSpinner = new JSpinner(new SpinnerNumberModel(0, 0, 99, 1));
JPanel levelPanel = new JPanel(new MigLayout("insets 0, wrap"));
levelPanel.add(SwingUtility.createTitleLabel("Level Gain"));
levelPanel.add(SwingUtility.createDescriptionLabel("Amount of levels to gain on waking up."));
levelPanel.add(levelSpinner, "grow");
add(levelPanel, "");
}
public void loadProfile(Player player) {
this.player = player;
gameVersion = player.getGameVersion();
String cgearType = player.getGameVersion().isVersion2() ? "CGEAR2" : "CGEAR";
customCGearSkin = player.getCustomCGearSkin() == null ? null : new DlcOption(cgearType, player.getCustomCGearSkin(), player.getCGearSkinFile().getAbsolutePath(), true);
customDexSkin = player.getCustomDexSkin() == null ? null : new DlcOption("ZUKAN", player.getCustomDexSkin(), player.getDexSkinFile().getAbsolutePath(), true);
customMusical = player.getCustomMusical() == null ? null : new DlcOption("MUSICAL", player.getCustomMusical(), player.getMusicalFile().getAbsolutePath(), true);
updateDlcOptions(cgearComboBox, cgearType, player.getCGearSkin(), customCGearSkin);
updateDlcOptions(zukanComboBox, "ZUKAN", player.getDexSkin(), customDexSkin);
updateDlcOptions(musicalComboBox, "MUSICAL", player.getMusical(), customMusical);
levelSpinner.setValue(player.getLevelsGained());
List<DreamDecor> decorList = player.getDecor();
for(int i = 0; i < DECOR_COUNT; i++) {
DecorOption option = decorOptions.get(i);
DreamDecor decor = i < decorList.size() ? decorList.get(i) : null;
option.setActive(decor != null);
option.setId(decor == null ? 0 : decor.id());
option.setName(decor == null ? "" : decor.name());
}
}
public void saveProfile(Player player) {
DlcOption cgearSkin = (DlcOption)cgearComboBox.getSelectedItem();
DlcOption dexSkin = (DlcOption)zukanComboBox.getSelectedItem();
DlcOption musical = (DlcOption)musicalComboBox.getSelectedItem();
player.setCGearSkin(cgearSkin == null ? null : cgearSkin.custom() ? "custom" : cgearSkin.name());
player.setDexSkin(dexSkin == null ? null : dexSkin.custom() ? "custom" : dexSkin.name());
player.setMusical(musical == null ? null : musical.custom() ? "custom" : musical.name());
player.setDecor(decorOptions.stream().filter(DecorOption::isActive).map(x -> new DreamDecor(x.getId(), x.getName())).toList());
player.setLevelsGained((int)levelSpinner.getValue());
}
private void createDlcOption(String label, JComboBox<DlcOption> comboBox, Runnable importListener) {
optionPanel.add(new JLabel(label), "sizegroup label");
optionPanel.add(comboBox, "sizegroup option");
JButton importButton = new JButton("Import");
importButton.addActionListener(event -> importListener.run());
optionPanel.add(importButton, "wrap");
}
private void updateDlcOptions(JComboBox<DlcOption> comboBox, String type, String selectedOption, DlcOption customOption) {
Vector<DlcOption> options = new Vector<>();
if(customOption != null) {
options.add(customOption);
}
entralinked.getDlcList().getDlcList("IRAO", type).forEach(dlc -> options.add(new DlcOption(type, dlc.name(), dlc.path())));
DlcOption selection = selectedOption == null ? null : selectedOption.equals("custom") ? customOption : options.stream().filter(x -> selectedOption.equals(x.name())).findFirst().orElse(null);
options.add(0, null); // "Do not change" option
comboBox.setModel(new DefaultComboBoxModel<DlcOption>(options));
comboBox.setSelectedItem(selection);
}
private void updateCustomOption(JComboBox<DlcOption> comboBox, DlcOption oldValue, DlcOption newValue) {
DefaultComboBoxModel<DlcOption> model = (DefaultComboBoxModel<DlcOption>)comboBox.getModel();
if(oldValue != null) {
model.removeElement(oldValue);
skinCache.remove(oldValue.path());
}
model.insertElementAt(newValue, 1);
model.setSelectedItem(newValue);
}
private static Image getSkinImage(DlcOption option) {
return option == null ? EMPTY_IMAGE : skinCache.computeIfAbsent(option.path(), path -> {
try(FileInputStream inputStream = new FileInputStream(path)) {
return switch(option.type()) {
case "CGEAR" -> TiledImageUtility.readCGearSkin(inputStream, true);
case "CGEAR2" -> TiledImageUtility.readCGearSkin(inputStream, false);
case "ZUKAN" -> TiledImageUtility.readDexSkin(inputStream, true);
default -> throw new IllegalArgumentException("Invalid type: " + option.type());
};
} catch(Exception e) {
return EMPTY_IMAGE; // TODO show feedback
}
});
}
private boolean importSkinFile(File src, File dst, int expectedSize) {
int sizeWithoutChecksum = expectedSize - 2;
int length = (int)src.length();
// Check content length
if(length != expectedSize && length != sizeWithoutChecksum) {
JOptionPane.showMessageDialog(getRootPane(), "Invalid content length, expected either %s or %s bytes."
.formatted(sizeWithoutChecksum, expectedSize), "Attention", JOptionPane.WARNING_MESSAGE);
return false;
}
try {
byte[] bytes = Files.readAllBytes(src.toPath());
boolean writeChecksum = true;
// Validate checksum
if(length == expectedSize) {
int checksum = Crc16.calc(bytes, 0, sizeWithoutChecksum);
int checksumInFile = (bytes[bytes.length - 2] & 0xFF) | ((bytes[bytes.length - 1] & 0xFF) << 8);
if(checksum != checksumInFile) {
JOptionPane.showMessageDialog(getRootPane(), "File checksum doesn't match.", "Attention", JOptionPane.WARNING_MESSAGE);
return false;
}
writeChecksum = false;
}
// Write to destination & append checksum if necessary
try(LEOutputStream outputStream = new LEOutputStream(new FileOutputStream(dst))) {
outputStream.write(bytes);
if(writeChecksum) {
outputStream.writeShort(Crc16.calc(bytes));
}
}
return true;
} catch(Exception e) {
e.printStackTrace(); // TODO show feedback
}
return false;
}
private boolean importSkinImage(File src, File dst, SkinWriter writer) {
try {
BufferedImage image = ImageIO.read(src);
int width = TiledImageUtility.SCREEN_WIDTH;
int height = TiledImageUtility.SCREEN_HEIGHT;
if(image.getWidth() != width || image.getHeight() != height) {
JOptionPane.showMessageDialog(getRootPane(), "Image size must be %sx%s pixels.".formatted(width, height), "Attention", JOptionPane.WARNING_MESSAGE);
return false;
}
ByteArrayOutputStream byteStream = new ByteArrayOutputStream();
writer.writeSkin(byteStream, image);
byte[] bytes = byteStream.toByteArray();
try(LEOutputStream outputStream = new LEOutputStream(new FileOutputStream(dst))) {
outputStream.write(bytes);
outputStream.writeShort(Crc16.calc(bytes));
}
return true;
} catch(IllegalArgumentException e) {
JOptionPane.showMessageDialog(getRootPane(), e.getMessage(), "Attention", JOptionPane.WARNING_MESSAGE);
} catch(Exception e) {
e.printStackTrace(); // TODO show feedback
}
return false;
}
private boolean importNarcFile(File src, File dst) {
try {
byte[] bytes = Files.readAllBytes(src.toPath());
int offset = 0;
// Check narc header
if(!Arrays.equals(bytes, 0, NARC_HEADER.length, NARC_HEADER, 0, NARC_HEADER.length)) {
if(bytes.length < 16 || !Arrays.equals(bytes, 16, 16 + NARC_HEADER.length, NARC_HEADER, 0, NARC_HEADER.length)) {
JOptionPane.showMessageDialog(getRootPane(), "Invalid or unsupported file.", "Attention", JOptionPane.WARNING_MESSAGE);
return false;
}
offset = 16;
}
// TODO utility function
int length = ((bytes[offset + NARC_HEADER.length + 3] & 0xFF) << 24)
| ((bytes[offset + NARC_HEADER.length + 2] & 0xFF) << 16)
| ((bytes[offset + NARC_HEADER.length + 1] & 0xFF) << 8)
| (bytes[offset + NARC_HEADER.length] & 0xFF);
if(offset + length >= bytes.length) {
JOptionPane.showMessageDialog(getRootPane(), "File data is malformed or corrupt.", "Attention", JOptionPane.WARNING_MESSAGE);
return false;
}
// Write to destination
try(LEOutputStream outputStream = new LEOutputStream(new FileOutputStream(dst))) {
outputStream.write(bytes, offset, length);
outputStream.writeShort(Crc16.calc(bytes, offset, length));
}
return true;
} catch(Exception e) {
e.printStackTrace(); // TODO show feedback
}
return false;
}
}

View File

@@ -7,8 +7,6 @@ import javax.swing.JLabel;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import com.formdev.flatlaf.FlatClientProperties;
import entralinked.gui.component.PropertyDisplay;
import entralinked.gui.component.ShadowedSprite;
import entralinked.gui.data.DataManager;
@@ -48,19 +46,12 @@ public class SummaryPanel extends JPanel {
public SummaryPanel() {
setLayout(new MigLayout("align 50% 50%, gapy 0, insets 0"));
// Create info labels
JLabel titleLabel = new JLabel("Summary");
titleLabel.putClientProperty(FlatClientProperties.STYLE, "font:bold +8");
flavorTextLabel = new JLabel();
flavorTextLabel.putClientProperty(FlatClientProperties.STYLE, "[dark]foreground:darken(@foreground,20%)");
JLabel subLabel = new JLabel("Tucked-in Pokémon info:");
subLabel.putClientProperty(FlatClientProperties.STYLE, "[dark]foreground:darken(@foreground,20%)");
// Create header panel
flavorTextLabel = SwingUtility.createDescriptionLabel();
JPanel headerPanel = new JPanel(new MigLayout("fillx, insets 0"));
headerPanel.add(titleLabel, "wrap");
headerPanel.add(SwingUtility.createTitleLabel("Summary"), "wrap");
headerPanel.add(flavorTextLabel, "wrap");
headerPanel.add(subLabel, "wrap");
headerPanel.add(SwingUtility.createDescriptionLabel("Tucked-in Pokémon info:"), "wrap");
add(headerPanel, "spanx");
// Create icon label

View File

@@ -20,8 +20,6 @@ import javax.swing.ListSelectionModel;
import javax.swing.table.DefaultTableModel;
import javax.swing.table.TableModel;
import com.formdev.flatlaf.FlatClientProperties;
import entralinked.gui.component.ConfigTable;
import entralinked.gui.component.ShadowedSprite;
import entralinked.utility.SwingUtility;
@@ -56,10 +54,8 @@ public abstract class TableEditorPanel extends JPanel {
System.arraycopy(columnNames, 0, columns, 1, columnNames.length);
// Create labels
titleLabel = new JLabel();
titleLabel.putClientProperty(FlatClientProperties.STYLE, "font:bold +8");
descriptionLabel = new JLabel();
descriptionLabel.putClientProperty(FlatClientProperties.STYLE, "[dark]foreground:darken(@foreground,20%)");
titleLabel = SwingUtility.createTitleLabel();
descriptionLabel = SwingUtility.createDescriptionLabel();
selectionIcon = new ShadowedSprite();
// Create buttons & toggles

View File

@@ -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);
}
}

View File

@@ -26,7 +26,7 @@ public enum AvenueVisitorType {
SCIENTIST_FEMALE("Scientist♀", 4, true),
// 5
HIKER("Hiker", 5),
HIKER("Hiker", 5),
PARASOL_LADY("Parasol Lady", 5, true),
// 6

View File

@@ -1,7 +1,19 @@
package entralinked.model.player;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonProperty;
public record DreamDecor(
@JsonProperty(required = true) int id,
@JsonProperty(required = true) String name) {}
@JsonProperty(required = true) String name) {
// TODO names probably differed per language
public static final List<DreamDecor> DEFAULT_DECOR = List.of(
new DreamDecor(1, "Design Table"),
new DreamDecor(2, "Design Stool"),
new DreamDecor(3, "Flower Vase"),
new DreamDecor(4, "Cuddle Rug"),
new DreamDecor(6, "Wall Poster")
);
}

View File

@@ -16,6 +16,7 @@ public class Player {
private final List<DreamEncounter> encounters = new ArrayList<>();
private final List<DreamItem> items = new ArrayList<>();
private final List<AvenueVisitor> avenueVisitors = new ArrayList<>();
private final List<DreamDecor> decor = new ArrayList<>();
private PlayerStatus status;
private GameVersion gameVersion;
private PkmnInfo dreamerInfo;
@@ -38,6 +39,8 @@ public class Player {
encounters.clear();
items.clear();
avenueVisitors.clear();
decor.clear();
decor.addAll(DreamDecor.DEFAULT_DECOR);
levelsGained = 0;
cgearSkin = null;
dexSkin = null;
@@ -81,6 +84,17 @@ public class Player {
return Collections.unmodifiableList(avenueVisitors);
}
public void setDecor(Collection<DreamDecor> decor) {
if(decor.size() <= 5) {
this.decor.clear();
this.decor.addAll(decor);
}
}
public List<DreamDecor> getDecor() {
return Collections.unmodifiableList(decor);
}
public void setStatus(PlayerStatus status) {
this.status = status;
}

View File

@@ -27,13 +27,14 @@ public record PlayerDto(
int levelsGained,
@JsonDeserialize(contentAs = DreamEncounter.class) Collection<DreamEncounter> encounters,
@JsonDeserialize(contentAs = DreamItem.class) Collection<DreamItem> items,
@JsonDeserialize(contentAs = AvenueVisitor.class) Collection<AvenueVisitor> avenueVisitors) {
@JsonDeserialize(contentAs = AvenueVisitor.class) Collection<AvenueVisitor> avenueVisitors,
@JsonDeserialize(contentAs = DreamDecor.class) Collection<DreamDecor> decor) {
public PlayerDto(Player player) {
this(player.getGameSyncId(), player.getGameVersion(), player.getStatus(), player.getDreamerInfo(),
player.getCGearSkin(), player.getDexSkin(), player.getMusical(), player.getCustomCGearSkin(),
player.getCustomDexSkin(), player.getCustomMusical(), player.getLevelsGained(), player.getEncounters(),
player.getItems(), player.getAvenueVisitors());
player.getItems(), player.getAvenueVisitors(), player.getDecor());
}
/**
@@ -54,6 +55,7 @@ public record PlayerDto(
player.setEncounters(encounters == null ? Collections.emptyList() : encounters);
player.setItems(items == null ? Collections.emptyList() : items);
player.setAvenueVisitors(avenueVisitors == null ? Collections.emptyList() : avenueVisitors);
player.setDecor(decor == null ? DreamDecor.DEFAULT_DECOR : decor);
return player;
}
}

View File

@@ -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;
}

View File

@@ -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;
};
}

View File

@@ -16,6 +16,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import entralinked.Configuration;
import entralinked.Entralinked;
import entralinked.GameVersion;
import entralinked.model.avenue.AvenueVisitor;
import entralinked.model.dlc.Dlc;
import entralinked.model.dlc.DlcList;
@@ -52,12 +53,6 @@ public class PglHandler implements HttpHandler {
private static final String password = "2Phfv9MY"; // Best security in the world
private final ObjectMapper mapper = new ObjectMapper(new UrlEncodedFormFactory()
.disable(UrlEncodedFormParser.Feature.BASE64_DECODE_VALUES));
private final List<DreamDecor> decorList = List.of(
new DreamDecor(1, "+----------+"),
new DreamDecor(2, "Thank you"),
new DreamDecor(3, "for using"),
new DreamDecor(4, "Entralinked!"),
new DreamDecor(5, "+----------+"));
private final Set<Integer> sleepyList = new HashSet<>();
private final Configuration configuration;
private final DlcList dlcList;
@@ -202,63 +197,26 @@ public class PglHandler implements HttpHandler {
return;
}
logger.info("Player {} is downloading save data as user {}", player.getGameSyncId(), user.getRedactedId());
logger.info("Player {} is downloading save data", player.getGameSyncId());
// Write status code
writeStatusCode(outputStream, 0);
// Allow it to wake up anyway, maybe the poor sap is stuck..
// Just don't send any other data.
// Allow waking up but don't send any data
if(player.getStatus() == PlayerStatus.AWAKE) {
return;
}
GameVersion version = player.getGameVersion();
List<DreamEncounter> encounters = player.getEncounters();
List<DreamItem> items = player.getItems();
List<DreamDecor> decorList = player.getDecor();
// Prepare DLC information
String cgearType = player.getGameVersion().isVersion2() ? "CGEAR2" : "CGEAR";
String cgearSkin = player.getCGearSkin();
String dexSkin = player.getDexSkin();
String musical = player.getMusical();
int cgearSkinIndex = 0;
int dexSkinIndex = 0;
int musicalIndex = 0;
// 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));
} else {
cgearSkinIndex = dlcList.getDlcIndex("IRAO", cgearType, cgearSkin);
user.removeDlcOverride(cgearType);
}
// 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));
} else {
dexSkinIndex = dlcList.getDlcIndex("IRAO", "ZUKAN", dexSkin);
user.removeDlcOverride("ZUKAN");
}
// Create or remove custom musical DLC override
if("custom".equals(musical)) {
musicalIndex = 1;
File file = player.getMusicalFile();
user.setDlcOverride("MUSICAL", new Dlc(file.getAbsolutePath(),
"custom", "IRAO", "MUSICAL", musicalIndex, (int)file.length(), 0, true));
} else {
musicalIndex = dlcList.getDlcIndex("IRAO", "MUSICAL", musical);
user.removeDlcOverride("MUSICAL");
}
// When waking up a Pokémon, these 4 bytes are written to 0x1D304 in the save file.
// If the bytes in the game's save file match the new bytes, they will be set to 0x00000000
// and no content will be downloaded.
// When waking up a Pokémon, these 4 bytes are written to 0x1D304 in the save file.
// If the bytes in the game's save file match the new bytes, they will be set to 0x00000000
// and no content will be downloaded.
// Looking at some old save files, this was very likely just a total tuck-in/wake-up counter.
// Additionally, waking up sets a flag at 0x1D4A3 (seems to be a "Pokémon is tucked in" flag or something) to 0x0.
outputStream.writeInt((int)(Math.random() * Integer.MAX_VALUE));
// Write encounter data (max 10)
@@ -277,10 +235,10 @@ public class PglHandler implements HttpHandler {
// Write misc stuff and DLC information
outputStream.writeShort(player.getLevelsGained());
outputStream.write(0); // Unknown
outputStream.write(musicalIndex);
outputStream.write(cgearSkinIndex);
outputStream.write(dexSkinIndex);
outputStream.write(decorList.isEmpty() ? 0 : 1); // Seems to be a flag for indicating whether or not decor data is present
outputStream.write(getDlcIndex(user, player.getMusical(), "MUSICAL", player.getMusicalFile()));
outputStream.write(getDlcIndex(user, player.getCGearSkin(), version.isVersion2() ? "CGEAR2" : "CGEAR", player.getCGearSkinFile()));
outputStream.write(getDlcIndex(user, player.getDexSkin(), "ZUKAN", player.getDexSkinFile()));
outputStream.write(decorList.isEmpty() ? 0 : 1); // Decor flag (?) stored at 0x1D4A4
outputStream.write(0); // Must be zero?
// Write item IDs
@@ -314,19 +272,23 @@ public class PglHandler implements HttpHandler {
}
// Write decor padding
outputStream.writeBytes(0, (5 - decorList.size()) * 26);
for(int i = 0; i < (5 - decorList.size()); i++) {
outputStream.writeShort(0x7E); // Just reset to default state
outputStream.writeBytes(0, 24);
}
outputStream.writeShort(0); // ?
// Join Avenue visitor data -- copied in parts to 0x2422C in the save file.
// Black Version 2 and White Version 2 only.
if(player.getGameVersion().isVersion2()) {
if(version.isVersion2()) {
List<AvenueVisitor> avenueVisitors = player.getAvenueVisitors();
for(AvenueVisitor visitor : avenueVisitors) {
// Write visitor name + padding. Names cannot be duplicate.
byte[] nameBytes = visitor.name().getBytes(StandardCharsets.UTF_16LE);
outputStream.write(nameBytes, 0, Math.min(14, nameBytes.length));
outputStream.writeBytes(-1, 14 - nameBytes.length);
outputStream.writeBytes(-1, 16 - nameBytes.length);
// 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.
@@ -335,7 +297,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
@@ -360,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");
@@ -451,13 +419,9 @@ public class PglHandler implements HttpHandler {
private void handleUploadSaveData(PglRequest request, Context ctx) throws IOException {
LEOutputStream outputStream = new LEOutputStream(ctx.outputStream());
Player player = playerManager.getPlayer(request.gameSyncId());
User user = ctx.attribute("user");
// 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();
@@ -470,8 +434,20 @@ public class PglHandler implements HttpHandler {
return;
}
logger.info("Player {} is uploading save data as user {}", player.getGameSyncId(), user.getRedactedId());
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
@@ -512,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;
}
@@ -551,6 +527,12 @@ 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(request.gameSyncId())) {
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
@@ -575,4 +557,17 @@ public class PglHandler implements HttpHandler {
outputStream.writeInt(status);
outputStream.writeBytes(0, 124);
}
/**
* Gets the index of the player's chosen DLC for the specified type and prepares DLC overriding if necessary.
*/
private int getDlcIndex(User user, String name, String type, File customFile) {
if("custom".equals(name)) {
user.setDlcOverride(type, new Dlc(customFile.getAbsolutePath(), name, "IRAO", type, 1, (int)customFile.length(), 0, true));
return 1;
} else {
user.removeDlcOverride(type);
return dlcList.getDlcIndex("IRAO", type, name);
}
}
}

View File

@@ -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));
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -108,6 +108,28 @@ public class SwingUtility {
return def;
}
public static JLabel createTitleLabel() {
return createTitleLabel("");
}
public static JLabel createTitleLabel(String text) {
return createStyledLabel(text, "font:bold +8");
}
public static JLabel createDescriptionLabel() {
return createDescriptionLabel("");
}
public static JLabel createDescriptionLabel(String text) {
return createStyledLabel(text, "[dark]foreground:darken(@foreground,20%)");
}
public static JLabel createStyledLabel(String text, String style) {
JLabel label = new JLabel(text);
label.putClientProperty(FlatClientProperties.STYLE, style);
return label;
}
public static JLabel createButtonLabel(String text, Runnable actionHandler) {
JLabel label = new JLabel("<html><u>%s</u></html>".formatted(text));
label.putClientProperty(FlatClientProperties.STYLE, "font: -1");

View File

@@ -1159,6 +1159,7 @@
324,
351
],
"formMask": 1,
"versionMask": 3327
},
{
@@ -1393,6 +1394,7 @@
173,
450
],
"formMask": 2,
"versionMask": 4095
},
{
@@ -2074,6 +2076,56 @@
575
]
},
"Pokémon Café Forest": {
"encounters": [
{
"species": 61,
"moves": [
114,
352
],
"versionMask": 4095
},
{
"species": 133,
"moves": [
129,
204
],
"versionMask": 4095
},
{
"species": 235,
"moves": [
214,
445
],
"versionMask": 4095
},
{
"species": 412,
"moves": [
173,
450
],
"formMask": 1,
"versionMask": 4095
}
],
"items": [
30,
31,
32,
33,
50,
82,
83,
84,
134,
157,
221
]
},
"Global Link Promotions": {
"encounters": [
{
@@ -2327,6 +2379,7 @@
{
"species": 493,
"moves": [],
"formMask": 1,
"versionMask": 3167
},
{

View File

@@ -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"));
}
}