37 Commits
v1.3.0 ... dev

Author SHA1 Message Date
kuroppoi
d99608ff11 More Mystery Gift shenanigans 2024-07-07 21:38:46 +02:00
kuroppoi
acbb3d7f95 Crappy support for Mystery Gifts (#42) 2024-07-06 23:44:30 +02:00
kuroppoi
abe1b80c2d Store player save & version data on Game Sync data creation (#36) 2024-01-14 22:34:07 +01:00
kuroppoi
8e614e0d27 Don't use config option 2024-01-10 01:39:34 +01:00
kuroppoi
774794f751 Redact user id in logs 2024-01-10 01:16:04 +01:00
kuroppoi
9d288bde09 Exclude a bunch of info from debug logs 2024-01-10 01:13:48 +01:00
kuroppoi
18b86a42e0 Disable loom 2024-01-10 00:20:49 +01:00
kuroppoi
39e0b27cec Update README.md 2023-12-29 22:08:44 +01:00
kuroppoi
1af975ffb6 Testing 2023-12-29 21:57:57 +01:00
kuroppoi
6f2e4a06a0 Preview gif 2023-12-29 21:46:26 +01:00
kuroppoi
68bdb7d47f Fix console logging woes 2023-11-19 15:41:42 +01:00
kuroppoi
163ced2333 Add debug logging 2023-11-19 15:17:21 +01:00
kuroppoi
f3809f4a54 Update README.md 2023-11-10 17:14:18 +01:00
kuroppoi
0cd628ba63 Merge workflows 2023-11-10 16:15:41 +01:00
kuroppoi
3898bd473c Fix browsers not remembering input Game Sync IDs (#22) 2023-09-20 11:40:53 +02:00
kuroppoi
0c4cd69741 Always log DNS server after startup (#15) 2023-08-24 19:04:55 +02:00
kuroppoi
91eb9971f3 Validate friend code checksum 2023-08-11 13:40:43 +02:00
kuroppoi
3224f6312a Only update id of the profile the user uses next when using the pid tool 2023-08-11 05:18:37 +02:00
kuroppoi
306b9e6db6 Add gui tool for fixing error 60000 2023-08-10 21:29:43 +02:00
kuroppoi
c0cc952cc2 Update README.md 2023-08-10 05:19:56 +02:00
kuroppoi
2eb8054ba7 Make Victini not downloadable 2023-07-25 20:13:40 +02:00
kuroppoi
d61196fea1 Minor tweaks 2023-07-24 17:43:50 +02:00
kuroppoi
8726614fce Update icons 2023-07-24 05:04:55 +02:00
kuroppoi
76c5d0e1a5 Update README.md 2023-07-24 03:41:20 +02:00
kuroppoi
86434b2cb1 Add icons 2023-07-24 03:28:04 +02:00
kuroppoi
724e30bfcc Abort as soon as one server fails to start 2023-07-23 04:27:52 +02:00
kuroppoi
87a06a4c08 Swing GUI makeover 2023-07-23 04:25:02 +02:00
kuroppoi
e06b83c944 Add some unit tests 2023-07-22 20:26:12 +02:00
kuroppoi
b890d27bba Fix incorrect animation type 2023-07-22 01:45:00 +02:00
kuroppoi
3771cac70a Pesky tabs 2023-07-17 23:33:46 +02:00
kuroppoi
2bdb04b88e Display skin preview in popup window instead of blank page 2023-07-17 17:21:28 +02:00
kuroppoi
99240e163a Fix crash that happens on startup after waking up a Pokémon 2023-07-16 22:55:57 +02:00
kuroppoi
72d5a10ffc Limit accepted file types 2023-07-15 23:18:19 +02:00
kuroppoi
6f1853fc50 Check if uploaded file is actually an image 2023-07-15 22:59:58 +02:00
kuroppoi
6999f1f976 Skip invalid tile mapping data 2023-07-15 22:23:51 +02:00
kuroppoi
16d1b99f1f Amend 229cfaf4ca 2023-07-15 21:20:47 +02:00
kuroppoi
229cfaf4ca Fix C-Gear skin preview inaccuracy 2023-07-15 21:11:31 +02:00
63 changed files with 1296 additions and 452 deletions

View File

@@ -1,13 +1,13 @@
name: Build
on:
workflow_dispatch:
pull_request:
push:
branches: [ master ]
paths:
- poke-sprites-v
- src/**
- build.gradle
jobs:
dist:
runs-on: ubuntu-latest

View File

@@ -1,21 +0,0 @@
name: Build on Pull Request
on: pull_request
jobs:
dist:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Checkout submodules
run: git submodule update --init --recursive
- name: Setup Java 17
uses: actions/setup-java@v3
with:
java-version: 17
distribution: temurin
- name: Make gradlew executable
run: chmod +x ./gradlew
- name: Setup Gradle
uses: gradle/gradle-build-action@v2
- name: Run Gradle dist
run: ./gradlew dist

View File

@@ -1,12 +1,21 @@
# Entralinked
[![build](https://github.com/kuroppoi/entralinked/actions/workflows/dist-upload-artifact.yml/badge.svg)](https://github.com/kuroppoi/entralinked/actions)
[![release](https://img.shields.io/github/v/release/kuroppoi/entralinked?labelColor=30373D&label=Release&logoColor=959DA5&logo=github)](https://github.com/kuroppoi/entralinked/releases/latest)
<p align="center">
<img src="https://raw.githubusercontent.com/kuroppoi/entralinked/master/images/icon.png" alt="icon"/>
</p>
<h1 align="center">Entralinked</h1>
<p align="center">
<a href="https://github.com/kuroppoi/entralinked/actions"><img src="https://github.com/kuroppoi/entralinked/actions/workflows/build.yml/badge.svg" alt="build"/></a>
<a href="https://github.com/kuroppoi/entralinked/releases/latest"><img src="https://img.shields.io/github/v/release/kuroppoi/entralinked?labelColor=30373D&label=Release&logoColor=959DA5&logo=github" alt="release"/></a>
</p>
Entralinked is a standalone Game Sync emulator developed for use with Pokémon Black & White and its sequels.\
Its purpose is to serve as a simple utility for downloading Pokémon, Items, C-Gear skins, Pokédex skins, Musicals\
and, in Black 2 & White 2 only, Join Avenue visitors to your game without needing to edit your save file.\
It can also be used to Memory Link with a Black or White save file if you don't have a second DS system.
![preview](https://raw.githubusercontent.com/kuroppoi/entralinked/master/images/preview.gif)
For users: [Quick Setup Guide](https://github.com/kuroppoi/entralinked/wiki/Setup)
## Building
#### Prerequisites
@@ -29,5 +38,4 @@ Entralinked has a built-in DNS server.\
In order for your game to connect, you must configure the DNS settings of your DS.\
By default, Entralinked is configured to automatically use the local host of the system.\
This approach is not always accurate, however, and you may need to manually configure it in `config.json`.\
If you receive error code `60000` when trying to connect, erase the WFC Configuration of your DS and try again.\
After tucking in a Pokémon, navigate to http://localhost/dashboard/profile.html to configure Game Sync settings.

View File

@@ -24,9 +24,12 @@ dependencies {
implementation 'org.apache.logging.log4j:log4j-api:2.20.0'
implementation 'com.fasterxml.jackson.core:jackson-databind:2.15.2'
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.15.2'
implementation 'io.netty:netty-all:4.1.79.Final'
implementation 'io.netty:netty-all:4.1.79.Final'
implementation 'io.javalin:javalin:5.5.0'
implementation 'org.apache.logging.log4j:log4j-slf4j2-impl:2.20.0'
implementation 'com.formdev:flatlaf:3.1.1'
implementation 'com.formdev:flatlaf-extras:3.1.1'
implementation 'com.formdev:flatlaf-intellij-themes:3.1.1'
}
sourceSets {

BIN
images/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
images/preview-2x.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

BIN
images/preview.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

View File

@@ -1,14 +1,14 @@
package entralinked;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
@JsonIgnoreProperties(ignoreUnknown = true)
public record Configuration(
@JsonProperty(required = true) String hostName,
@JsonProperty(required = true) boolean clearPlayerDreamInfoOnWake,
@JsonProperty(required = true) boolean allowOverwritingPlayerDreamInfo,
@JsonProperty(required = true) boolean allowWfcRegistrationThroughLogin) {
String hostName,
boolean clearPlayerDreamInfoOnWake,
boolean allowOverwritingPlayerDreamInfo,
boolean allowPlayerGameVersionMismatch,
boolean allowWfcRegistrationThroughLogin) {
public static final Configuration DEFAULT = new Configuration("local", true, false, true);
public static final Configuration DEFAULT = new Configuration("local", true, false, false, true);
}

View File

@@ -43,8 +43,11 @@ public class Entralinked {
private final GameSpyServer gameSpyServer;
private final HttpServer httpServer;
private MainView mainView;
private boolean initialized;
public Entralinked(String[] args) {
long beginTime = System.currentTimeMillis();
// Read command line arguments
CommandLineArguments arguments = new CommandLineArguments(args);
@@ -92,16 +95,11 @@ public class Entralinked {
userManager = new UserManager();
playerManager = new PlayerManager();
// Start servers
boolean started = true;
// Create DNS server
dnsServer = new DnsServer(hostAddress);
started &= dnsServer.start();
// Create GameSpy server
gameSpyServer = new GameSpyServer(this);
started &= gameSpyServer.start();
// Create HTTP server
httpServer = new HttpServer(this);
@@ -109,25 +107,42 @@ public class Entralinked {
httpServer.addHandler(new PglHandler(this));
httpServer.addHandler(new DlsHandler(this));
httpServer.addHandler(new DashboardHandler(this));
started &= httpServer.start();
// Handle post-startup GUI stuff
if(mainView != null) {
if(!started) {
SwingUtilities.invokeLater(() -> mainView.setStatusLabelText(
"ERROR: One or more servers failed to start! Please check the logs for info."));
return;
}
// Start servers
boolean started = startServers();
// Post-startup
if(started) {
String hostIpAddress = hostAddress.getHostAddress();
SwingUtilities.invokeLater(() -> {
mainView.setDashboardButtonEnabled(true);
mainView.setStatusLabelText("Configure your DS to use the following DNS server: %s".formatted(hostIpAddress));
});
logger.info("Startup complete! Took a total of {} milliseconds", System.currentTimeMillis() - beginTime);
logger.info("Configure your DS to use the following DNS server: {}", hostIpAddress);
if(mainView != null) {
SwingUtilities.invokeLater(() -> {
mainView.setDashboardButtonEnabled(true);
mainView.setStatusLabelText("Configure your DS to use the following DNS server: %s".formatted(hostIpAddress));
});
}
} else {
stopServers();
if(mainView != null) {
SwingUtilities.invokeLater(() -> mainView.setStatusLabelText(
"ERROR: Entralinked failed to start. Please check the logs for info."));
}
}
initialized = true;
}
public boolean startServers() {
logger.info("Starting servers ...");
return httpServer.start() && gameSpyServer.start() && dnsServer.start();
}
public void stopServers() {
logger.info("Stopping servers ...");
if(httpServer != null) {
httpServer.stop();
}
@@ -143,21 +158,25 @@ public class Entralinked {
private Configuration loadConfigFile() {
logger.info("Loading configuration ...");
Configuration configuration = null;
try {
File configFile = new File("config.json");
if(!configFile.exists()) {
logger.info("No configuration file exists - default configuration will be used");
mapper.writeValue(configFile, Configuration.DEFAULT);
return Configuration.DEFAULT;
configuration = Configuration.DEFAULT;
} else {
return mapper.readValue(configFile, Configuration.class);
configuration = mapper.readValue(configFile, Configuration.class);
}
mapper.writeValue(configFile, configuration);
} catch(IOException e) {
logger.error("Could not load configuration - default configuration will be used", e);
return Configuration.DEFAULT;
configuration = Configuration.DEFAULT;
}
return configuration;
}
public Configuration getConfiguration() {
@@ -175,4 +194,8 @@ public class Entralinked {
public PlayerManager getPlayerManager() {
return playerManager;
}
public boolean isInitialized() {
return initialized;
}
}

View File

@@ -1,6 +1,7 @@
package entralinked.gui;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Desktop;
import java.awt.Dimension;
import java.awt.Font;
@@ -9,16 +10,19 @@ import java.awt.event.WindowEvent;
import java.io.IOException;
import java.net.URISyntaxException;
import java.net.URL;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import javax.swing.ImageIcon;
import javax.swing.JButton;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JMenu;
import javax.swing.JMenuBar;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTextPane;
import javax.swing.UIManager;
import javax.swing.UnsupportedLookAndFeelException;
import javax.swing.text.AttributeSet;
import javax.swing.text.BadLocationException;
import javax.swing.text.DefaultCaret;
@@ -27,30 +31,31 @@ import javax.swing.text.SimpleAttributeSet;
import javax.swing.text.StyleConstants;
import javax.swing.text.StyleContext;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.Level;
import com.formdev.flatlaf.intellijthemes.FlatOneDarkIJTheme;
import entralinked.Entralinked;
import entralinked.utility.ConsumerAppender;
import entralinked.utility.SwingUtility;
/**
* Simple Swing user interface.
*/
public class MainView {
private static Logger logger = LogManager.getLogger();
public static final Color TEXT_COLOR = Color.WHITE.darker();
public static final Color TEXT_COLOR_WARN = Color.YELLOW.darker();
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 JButton dashboardButton;
private final JLabel statusLabel;
public MainView(Entralinked entralinked) {
// Try set Look and Feel
try {
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
} catch (ReflectiveOperationException | UnsupportedLookAndFeelException e) {
logger.error("Could not set Look and Feel", e);
}
// Set look and feel
FlatOneDarkIJTheme.setup();
UIManager.getDefaults().put("Component.focusedBorderColor", UIManager.get("Component.borderColor"));
// Create dashboard button
dashboardButton = new JButton("Open User Dashboard");
@@ -87,8 +92,12 @@ public class MainView {
// Create console output appender
ConsumerAppender.addConsumer("GuiOutput", message -> {
Document document = consoleOutputPane.getDocument();
Level level = message.level();
Color color = level == Level.ERROR ? TEXT_COLOR_ERROR : level == Level.WARN ? TEXT_COLOR_WARN : TEXT_COLOR;
AttributeSet colorAttribute = styleContext.addAttribute(fontAttribute, StyleConstants.Foreground, color);
try {
consoleOutputPane.getDocument().insertString(document.getLength(), message, fontAttribute);
consoleOutputPane.getDocument().insertString(document.getLength(), message.formattedMessage(), colorAttribute);
} catch(BadLocationException e) {}
});
@@ -104,6 +113,15 @@ public class MainView {
// Create window
JFrame frame = new JFrame("Entralinked");
// Create menu bar
JMenuBar menuBar = new JMenuBar();
JMenu helpMenu = new JMenu("Help");
helpMenu.add(SwingUtility.createAction("Update PID (Error 60000)", () -> new PidToolDialog(entralinked, frame)));
helpMenu.add(SwingUtility.createAction("GitHub", () -> openUrl("https://github.com/kuroppoi/entralinked")));
menuBar.add(helpMenu);
// Set window properties
frame.addWindowListener(new WindowAdapter() {
@Override
public void windowClosing(WindowEvent event) {
@@ -119,8 +137,13 @@ public class MainView {
});
}
});
frame.setIconImages(List.of(
new ImageIcon(getClass().getResource("/icon-64x.png")).getImage(),
new ImageIcon(getClass().getResource("/icon-32x.png")).getImage(),
new ImageIcon(getClass().getResource("/icon-16x.png")).getImage()));
frame.setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE);
frame.setMinimumSize(new Dimension(512, 288));
frame.setJMenuBar(menuBar);
frame.add(panel);
frame.pack();
frame.setLocationRelativeTo(null);
@@ -131,7 +154,6 @@ public class MainView {
dashboardButton.setEnabled(enabled);
}
public void setStatusLabelText(String text) {
statusLabel.setText(text);
}

View File

@@ -0,0 +1,116 @@
package entralinked.gui;
import java.awt.GridBagLayout;
import java.util.regex.Pattern;
import javax.swing.BorderFactory;
import javax.swing.JButton;
import javax.swing.JDialog;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import com.formdev.flatlaf.extras.components.FlatTextField;
import entralinked.Entralinked;
import entralinked.model.user.User;
import entralinked.utility.MD5;
import entralinked.utility.SwingUtility;
public class PidToolDialog {
public static final Pattern WFC_ID_PATTERN = Pattern.compile("[0-9]{16}");
public static final Pattern FRIEND_CODE_PATTERN = Pattern.compile("[0-9]{12}");
public PidToolDialog(Entralinked entralinked, JFrame frame) {
// Create dialog
JDialog dialog = new JDialog(frame, "PID Tool");
// Create input fields
FlatTextField wfcIdField = new FlatTextField();
wfcIdField.setPlaceholderText("XXXX-XXXX-XXXX-XXXX");
FlatTextField friendCodeField = new FlatTextField();
friendCodeField.setPlaceholderText("XXXX-XXXX-XXXX");
// Create logic
JButton updateButton = new JButton("Update");
updateButton.addActionListener(event -> {
if(!entralinked.isInitialized()) {
JOptionPane.showMessageDialog(dialog, "Please wait for Entralinked to finish starting.", "Attention", JOptionPane.WARNING_MESSAGE);
return;
}
String userId = wfcIdField.getText().replace("-", "").replaceAll("\\s+", "");
String friendCodeString = friendCodeField.getText().replace("-", "").replaceAll("\\s+", "");
// Make sure WFC ID is valid
if(!WFC_ID_PATTERN.matcher(userId).matches()) {
JOptionPane.showMessageDialog(dialog, "Please enter a valid Wi-Fi Connection ID.", "Attention", JOptionPane.WARNING_MESSAGE);
return;
}
// Make sure Friend Code is valid
if(!FRIEND_CODE_PATTERN.matcher(friendCodeString).matches()) {
JOptionPane.showMessageDialog(dialog, "Please enter a valid Friend Code.", "Attention", JOptionPane.WARNING_MESSAGE);
return;
}
User user = entralinked.getUserManager().getUser(userId.substring(0, 13));
// Make sure user exists
if(user == null) {
JOptionPane.showMessageDialog(dialog, "This Wi-Fi Connection ID does not exist.", "Attention", JOptionPane.WARNING_MESSAGE);
return;
}
long friendCode = Long.parseLong(friendCodeString);
int profileId = (int)(friendCode & 0x7FFFFFFF);
int checksum = (int)(friendCode >> 32);
// Compute friend code checksum
byte[] buffer = {0x00, 0x00, 0x00, 0x00, 0x4A, 0x41, 0x52, 0x49}; // Last 4 bytes is inverted game code (IRAJ)
buffer[0] = (byte)(profileId & 0xFF);
buffer[1] = (byte)(profileId >> 8 & 0xFF);
buffer[2] = (byte)(profileId >> 16 & 0xFF);
buffer[3] = (byte)(profileId >> 24 & 0xFF);
byte[] hash = MD5.digest(buffer);
int computedChecksum = hash[0] >> 1 & 0x7F;
// Compare checksums
if(computedChecksum != checksum) {
JOptionPane.showMessageDialog(dialog, "This is not a valid Friend Code. Please check for typos.", "Attention", JOptionPane.WARNING_MESSAGE);
return;
}
// Everything checks out -- update the profile id!
user.setProfileIdOverride(profileId);
JOptionPane.showMessageDialog(dialog,
"All done! Please restart your game and use Game Sync.\nGame profile data will be updated and saved once you do so.");
});
// Create content panel
JPanel panel = new JPanel(new GridBagLayout());
String infoLabel = """
<html>
Enter the Wi-Fi Connection ID found in the internet settings of your DS<br>
as well as your Friend Code which you can view in-game using the Pal Pad.<br>
Confirm that the input data is correct and press 'Update'<br>
</html>
""";
panel.setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5));
panel.add(new JLabel(infoLabel), SwingUtility.createConstraints(0, 0, 2, 1));
panel.add(new JLabel("Wi-Fi Connection ID"), SwingUtility.createConstraints(0, 1, 1, 1, 0, 1));
panel.add(wfcIdField, SwingUtility.createConstraints(1, 1));
panel.add(new JLabel("Friend Code"), SwingUtility.createConstraints(0, 2, 1, 1, 0, 1));
panel.add(friendCodeField, SwingUtility.createConstraints(1, 2));
panel.add(updateButton, SwingUtility.createConstraints(0, 3, 2, 1));
// Set dialog properties
dialog.setResizable(false);
dialog.add(panel);
dialog.pack();
dialog.setLocationRelativeTo(frame);
dialog.setVisible(true);
}
}

View File

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

View File

@@ -6,6 +6,7 @@ import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
@@ -17,7 +18,9 @@ import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import entralinked.utility.Crc16;
import entralinked.utility.MD5;
@Deprecated
public class DlcList {
private static final Logger logger = LogManager.getLogger();
@@ -27,31 +30,32 @@ public class DlcList {
public DlcList() {
logger.info("Loading DLC ...");
// Extract defaults if external DLC directory is not present
if(!dataDirectory.exists()) {
logger.info("Extracting default DLC files ...");
BufferedReader reader = new BufferedReader(new InputStreamReader(getClass().getResourceAsStream("/dlc.paths")));
String line = null;
try {
while((line = reader.readLine()) != null) {
InputStream resource = getClass().getResourceAsStream(line);
File outputFile = new File("./%s".formatted(line));
// Create parent directories
if(outputFile.getParentFile() != null) {
outputFile.getParentFile().mkdirs();
}
// Copy resource to destination
if(resource != null) {
Files.copy(resource, outputFile.toPath());
}
try(BufferedReader reader = new BufferedReader(new InputStreamReader(getClass().getResourceAsStream("/dlc.paths")))) {
reader.lines().forEach(line -> {
String[] segments = line.split("\t");
if(segments.length != 2) {
return;
}
} catch (IOException e) {
logger.error("Could not extract default DLC files", e);
return;
}
String path = segments[0];
String hash = segments[1];
File outputFile = new File("./%s".formatted(path));
if(outputFile.getParentFile() != null) {
outputFile.getParentFile().mkdirs();
}
try(InputStream inputStream = getClass().getResourceAsStream(path)){
if(!outputFile.exists() || !hash.equals(MD5.digest(outputFile))) {
Files.copy(inputStream, outputFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
}
} catch(IOException e) {
logger.error("Couldn't process resource '{}'", path, e);
}
});
} catch(IOException e) {
logger.error("Couldn't extract DLC data", e);
}
// Just to be sure...
@@ -71,7 +75,7 @@ public class DlcList {
for(File subFile : file.listFiles()) {
// Check if file is directory
if(!subFile.isDirectory()) {
logger.warn("Non-directory '{}' in DLC subfolder '{}'", file.getName(), subFile.getName());
logger.warn("Non-directory '{}' in DLC subfolder '{}'", subFile.getName(), file.getName());
continue;
}

View File

@@ -33,7 +33,6 @@ public class Player {
public void resetDreamInfo() {
status = PlayerStatus.AWAKE;
gameVersion = null;
dreamerInfo = null;
encounters.clear();
items.clear();

View File

@@ -17,6 +17,8 @@ import org.apache.logging.log4j.Logger;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import entralinked.GameVersion;
/**
* Manager class for managing {@link Player} information (Global Link users)
*/
@@ -175,7 +177,7 @@ public class PlayerManager {
* That is, the specified Game Sync ID wasn't already registered and the player data
* was saved without any errors.
*/
public Player registerPlayer(String gameSyncId) {
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);
@@ -193,6 +195,7 @@ public class PlayerManager {
// Construct player object
Player player = new Player(gameSyncId);
player.setStatus(PlayerStatus.AWAKE);
player.setGameVersion(version);
player.setDataDirectory(playerDataDirectory);
// Try to save player data

View File

@@ -2,7 +2,7 @@ package entralinked.model.user;
public class GameProfile {
private final int id;
private int id;
private String firstName;
private String lastName;
private String aimName;
@@ -20,6 +20,10 @@ public class GameProfile {
this.zipCode = zipCode;
}
public void setId(int id) {
this.id = id;
}
public int getId() {
return id;
}

View File

@@ -1,18 +1,18 @@
package entralinked.model.user;
import java.io.File;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import entralinked.model.dlc.Dlc;
public class User {
private final String id;
private final String password; // I debated hashing it, but.. it's a 3-digit password...
private final Map<String, GameProfile> profiles = new HashMap<>();
private final Map<String, Dlc> dlcOverrides = new HashMap<>();
private final Map<String, File> dlcOverrides = new HashMap<>();
private int profileIdOverride; // For making it easier for the user to fix error 60000
public User(String id, String password) {
this.id = id;
@@ -23,6 +23,13 @@ public class User {
return "%s000".formatted(id).replaceAll("(.{4})(?!$)", "$1-");
}
/**
* @return The user's id, redacted for logging.
*/
public String getRedactedId() {
return "%s-XXXX-XXXX-XXXX".formatted(id.substring(0, 4));
}
public String getId() {
return id;
}
@@ -51,11 +58,11 @@ public class User {
return Collections.unmodifiableMap(profiles);
}
public void setDlcOverride(String type, Dlc target) {
if(target == null) {
public void setDlcOverride(String type, File file) {
if(file == null) {
dlcOverrides.remove(type);
} else {
dlcOverrides.put(type, target);
dlcOverrides.put(type, file);
}
}
@@ -67,7 +74,15 @@ public class User {
return dlcOverrides.containsKey(type);
}
public Dlc getDlcOverride(String type) {
public File getDlcOverride(String type) {
return dlcOverrides.get(type);
}
public void setProfileIdOverride(int profileIdOverride) {
this.profileIdOverride = profileIdOverride;
}
public int getProfileIdOverride() {
return profileIdOverride;
}
}

View File

@@ -30,7 +30,6 @@ public class UserManager {
private static final Logger logger = LogManager.getLogger();
private final ObjectMapper mapper = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT);
private final Map<String, User> users = new ConcurrentHashMap<>();
private final Map<Integer, GameProfile> profiles = new ConcurrentHashMap<>();
private final Map<String, ServiceSession> serviceSessions = new ConcurrentHashMap<>();
private final File dataDirectory = new File("users");
@@ -49,7 +48,7 @@ public class UserManager {
}
}
logger.info("Loaded {} user(s) with a total of {} profile(s)", users.size(), profiles.size());
logger.info("Loaded {} user(s)", users.size());
}
/**
@@ -75,20 +74,8 @@ public class UserManager {
throw new IOException("Duplicate user ID %s".formatted(id));
}
// Check for duplicate profile IDs before indexing anything
Collection<GameProfile> userProfiles = user.getProfiles();
if(userProfiles.stream().map(GameProfile::getId).anyMatch(profiles::containsKey)) {
throw new IOException("Duplicate profile ID in user %s".formatted(id));
}
// Index user
users.put(id, user);
// Index profiles
for(GameProfile profile : userProfiles) {
profiles.put(profile.getId(), profile);
}
} catch(IOException e) {
logger.error("Could not load user data at {}", inputFile.getAbsolutePath(), e);
}
@@ -190,14 +177,14 @@ public class UserManager {
/**
* Registers a user with the given ID and password.
* @return {@code true} if the registration was successful.
* Otherwise, if this user ID has already been registered, or if the user data could not be saved, {@code false} is returned instead.
* @return The {@link User} object of the newly registered user if the registration was successful.
* Otherwise, if this user ID has already been registered, or if the user data could not be saved, {@code null} is returned instead.
*/
public boolean registerUser(String userId, String plainPassword) {
public User registerUser(String userId, String plainPassword) {
// Check if user id already exists
if(users.containsKey(userId)) {
logger.warn("Attempted to register user with duplicate ID: {}", userId);
return false;
return null;
}
// Create user
@@ -205,11 +192,11 @@ public class UserManager {
// Save user data and return null if it fails
if(!saveUser(user)) {
return false;
return null;
}
users.put(userId, user);
return true;
return user;
}
/**
@@ -233,7 +220,7 @@ public class UserManager {
return null;
}
int profileId = nextProfileId();
int profileId = (int)(Math.random() * Integer.MAX_VALUE);
GameProfile profile = new GameProfile(profileId);
user.addProfile(branchCode, profile);
@@ -243,24 +230,9 @@ public class UserManager {
return null;
}
profiles.put(profileId, profile);
return profile;
}
/**
* @return A unique random 32-bit profile ID.
*/
private int nextProfileId() {
int profileId = (int)(Math.random() * Integer.MAX_VALUE);
// I live for that microscopic chance of StackOverflowError
if(profiles.containsKey(profileId)) {
return nextProfileId();
}
return profileId;
}
/**
* @return {@code true} if a user with the specified ID exists, otherwise {@code false}.
*/

View File

@@ -40,7 +40,6 @@ public abstract class NettyServerBase {
public boolean start() {
if(started) {
logger.warn("start() was called while {} server was already running!", name);
return true;
}
@@ -59,7 +58,6 @@ public abstract class NettyServerBase {
public boolean stop() {
if(!started) {
logger.warn("stop() was called while {} server wasn't running!", name);
return true;
}

View File

@@ -28,6 +28,7 @@ public class DnsQueryHandler extends SimpleChannelInboundHandler<DatagramDnsQuer
@Override
protected void channelRead0(ChannelHandlerContext ctx, DatagramDnsQuery query) throws Exception {
DefaultDnsQuestion question = query.recordAt(DnsSection.QUESTION);
logger.debug("Received {}", question);
DnsRecordType type = question.type();
// We only need type A (32 bit IPv4) for the DS

View File

@@ -2,9 +2,6 @@ package entralinked.network.dns;
import java.net.InetAddress;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import entralinked.network.NettyServerBase;
import io.netty.bootstrap.Bootstrap;
import io.netty.channel.Channel;
@@ -17,13 +14,11 @@ import io.netty.handler.codec.dns.DatagramDnsResponseEncoder;
public class DnsServer extends NettyServerBase {
private static final Logger logger = LogManager.getLogger();
private InetAddress hostAddress;
public DnsServer(InetAddress hostAddress) {
super("DNS", 53);
this.hostAddress = hostAddress;
logger.info("DNS queries will be resolved to {}", hostAddress.getHostAddress());
}
@Override

View File

@@ -28,6 +28,7 @@ import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.timeout.ReadTimeoutException;
/**
* GameSpy request handler.
@@ -50,6 +51,7 @@ public class GameSpyHandler extends SimpleChannelInboundHandler<GameSpyRequest>
@Override
public void channelActive(ChannelHandlerContext ctx) {
channel = ctx.channel();
logger.debug("Sending GameSpy server challenge");
// Generate random server challenge
serverChallenge = CredentialGenerator.generateChallenge(10);
@@ -60,6 +62,8 @@ public class GameSpyHandler extends SimpleChannelInboundHandler<GameSpyRequest>
@Override
public void channelInactive(ChannelHandlerContext ctx) {
logger.debug("User {} disconnected from GameSpy server", user == null ? null : user.getRedactedId());
// Clear data
serverChallenge = null;
sessionKey = -1;
@@ -69,15 +73,23 @@ public class GameSpyHandler extends SimpleChannelInboundHandler<GameSpyRequest>
@Override
protected void channelRead0(ChannelHandlerContext ctx, GameSpyRequest request) throws Exception {
logger.debug("Received {}", request);
request.process(this);
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
if(cause instanceof ClosedChannelException) {
logger.debug("Silently discarded ClosedChannelException");
return; // Ignore this stupid exception.
}
// Handle timeout
if(cause instanceof ReadTimeoutException) {
logger.debug("User {} timed out", user == null ? null : user.getRedactedId());
return;
}
logger.error("Exception caught in GameSpy handler", cause);
// Send error message and close channel afterwards.
@@ -92,6 +104,7 @@ public class GameSpyHandler extends SimpleChannelInboundHandler<GameSpyRequest>
ServiceSession session = userManager.getServiceSession(authToken, "gamespy");
if(session == null) {
logger.debug("Rejecting GameSpy login request because the partner token is invalid");
sendErrorMessage(0x200, "Invalid partner token.", request.sequenceId());
return;
}
@@ -101,6 +114,7 @@ public class GameSpyHandler extends SimpleChannelInboundHandler<GameSpyRequest>
String expectedResponse = createCredentialHash(partnerChallengeHash, authToken, clientChallenge, serverChallenge);
if(!expectedResponse.equals(request.response())) {
logger.debug("Rejecting GameSpy login request because the challenge response is invalid");
sendErrorMessage(0x202, "Invalid response.", request.sequenceId());
return;
}
@@ -119,6 +133,17 @@ public class GameSpyHandler extends SimpleChannelInboundHandler<GameSpyRequest>
}
}
// Update profile id if an override is set
int profileIdOverride = user.getProfileIdOverride();
if(profileIdOverride > 0) {
profile.setId(profileIdOverride);
user.setProfileIdOverride(0);
userManager.saveUser(user); // It's not too big of a deal if this fails for some reason
}
logger.info("User {} logged in with profile {}", user.getRedactedId(), profile.getId());
// Prepare and send response
sessionKey = secureRandom.nextInt(Integer.MAX_VALUE);
String proof = createCredentialHash(partnerChallengeHash, authToken, serverChallenge, clientChallenge);
@@ -171,10 +196,9 @@ public class GameSpyHandler extends SimpleChannelInboundHandler<GameSpyRequest>
passwordHash));
}
public void destroySessionKey(int sessionKey) {
if(validateSessionKey(sessionKey)) {
sessionKey = -1;
}
public void handleLogout() {
logger.info("User {} logged out of profile {}", user.getRedactedId(), profile.getId());
sessionKey = -1; // Is there a point?
}
public boolean validateSessionKey(int sessionKey) {

View File

@@ -26,6 +26,7 @@ import io.netty.channel.ChannelPipeline;
import io.netty.channel.epoll.EpollServerSocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.DelimiterBasedFrameDecoder;
import io.netty.handler.timeout.ReadTimeoutHandler;
import io.netty.util.concurrent.DefaultEventExecutor;
import io.netty.util.concurrent.EventExecutorGroup;
@@ -64,6 +65,7 @@ public class GameSpyServer extends NettyServerBase {
@Override
protected void initChannel(Channel channel) throws Exception {
ChannelPipeline pipeline = channel.pipeline();
pipeline.addLast(new ReadTimeoutHandler(180));
pipeline.addLast(new DelimiterBasedFrameDecoder(512, Unpooled.wrappedBuffer("\\final\\".getBytes())));
pipeline.addLast(new GameSpyRequestDecoder(mapper, requestTypes));
pipeline.addLast(new GameSpyMessageEncoder(mapper));

View File

@@ -1,13 +1,9 @@
package entralinked.network.gamespy.request;
import entralinked.network.gamespy.GameSpyHandler;
import entralinked.network.gamespy.message.GameSpyMessage;
@GameSpyMessage(name = "ka")
public record GameSpyKeepAliveRequest() implements GameSpyRequest {
@Override
public void process(GameSpyHandler handler) {
handler.sendMessage(this); // Pong it back
}
public void process(GameSpyHandler handler) {}
}

View File

@@ -28,4 +28,11 @@ public record GameSpyLoginRequest(
public void process(GameSpyHandler handler) {
handler.handleLoginRequest(this);
}
@Override
public String toString() {
return ("GameSpyLoginRequest[sequenceId=%s, gameName=%s, profileId=%s, namespaceId=%s, partnerId=%s, productId=%s, "
+ "sdkRevision=%s, firewall=%s, port=%s, quiet=%s]")
.formatted(sequenceId, gameName, profileId, namespaceId, partnerId, productId, sdkRevision, firewall, port, quiet);
}
}

View File

@@ -10,6 +10,14 @@ public record GameSpyLogoutRequest(
@Override
public void process(GameSpyHandler handler) {
handler.destroySessionKey(sessionKey);
if(handler.validateSessionKey(sessionKey)) {
handler.handleLogout();
}
}
@Override
public String toString() {
// Exlude session key
return "GameSpyLogoutRequest[]";
}
}

View File

@@ -16,4 +16,10 @@ public record GameSpyProfileRequest(
handler.handleProfileRequest(this);
}
}
@Override
public String toString() {
// Exlude session key
return "GameSpyProfileRequest[sequenceId=%s, profileId=%s]".formatted(sequenceId, profileId);
}
}

View File

@@ -21,4 +21,9 @@ public record GameSpyProfileUpdateRequest(
handler.handleUpdateProfileRequest(this);
}
}
@Override
public String toString() {
return "GameSpyProfileUpdateRequest[partnerId=%s]".formatted(partnerId);
}
}

View File

@@ -14,4 +14,9 @@ public record GameSpyStatusRequest(
public void process(GameSpyHandler handler) {
handler.validateSessionKey(sessionKey);
}
@Override
public String toString() {
return "GameSpyStatusRequest[]";
}
}

View File

@@ -23,6 +23,7 @@ import entralinked.LauncherAgent;
import entralinked.utility.CertificateGenerator;
import io.javalin.Javalin;
import io.javalin.http.HttpStatus;
import io.javalin.util.ConcurrencyUtil;
import io.javalin.util.JavalinException;
public class HttpServer {
@@ -32,7 +33,9 @@ public class HttpServer {
private final Javalin javalin;
private boolean started;
public HttpServer(Entralinked entralinked) {
public HttpServer(Entralinked entralinked) {
ConcurrencyUtil.INSTANCE.setUseLoom(false);
// Create certificate keystore
KeyStore keyStore = null;
@@ -75,7 +78,6 @@ public class HttpServer {
public boolean start() {
if(started) {
logger.warn("start() was called while HTTP server was already running!");
return true;
}
@@ -94,7 +96,6 @@ public class HttpServer {
public boolean stop() {
if(!started) {
logger.warn("stop() was called while HTTP server wasn't running!");
return true;
}

View File

@@ -92,7 +92,7 @@ public class DashboardHandler implements HttpHandler {
// Cache the result
skinPreviewCache.put("%s/%s".formatted(player.getGameSyncId(), version2 ? "CGEAR2" : "CGEAR"), image);
} catch(IOException | IndexOutOfBoundsException e) {
} catch(IOException | IndexOutOfBoundsException | NullPointerException e) {
logger.error("Could not load custom C-Gear skin preview for player {}", player.getGameSyncId(), e);
}
}
@@ -284,6 +284,12 @@ public class DashboardHandler implements HttpHandler {
BufferedImage image = ImageIO.read(ctx.bodyInputStream());
BufferedImage previewImage = null;
// Make sure file is a valid image
if(image == null) {
ctx.json(new DashboardStatusMessage("File is either not an image or uses an unsupported format.", true));
return;
}
// Make sure image has the correct dimensions
if(image.getWidth() != 256 || image.getHeight() != 192) {
ctx.json(new DashboardStatusMessage("Image must be 256 x 192 pixels.", true));

View File

@@ -1,21 +1,27 @@
package entralinked.network.http.dls;
import java.io.FileInputStream;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import com.fasterxml.jackson.databind.ObjectMapper;
import entralinked.Entralinked;
import entralinked.model.dlc.Dlc;
import entralinked.model.dlc.DlcList;
import entralinked.GameVersion;
import entralinked.model.user.ServiceSession;
import entralinked.model.user.User;
import entralinked.model.user.UserManager;
import entralinked.network.http.HttpHandler;
import entralinked.network.http.HttpRequestHandler;
import entralinked.serialization.UrlEncodedFormFactory;
import entralinked.utility.LEOutputStream;
import entralinked.utility.MysteryGiftUtility;
import io.javalin.Javalin;
import io.javalin.http.Context;
import io.javalin.http.HttpStatus;
@@ -25,12 +31,12 @@ import io.javalin.http.HttpStatus;
*/
public class DlsHandler implements HttpHandler {
private static final Logger logger = LogManager.getLogger();
private final ObjectMapper mapper = new ObjectMapper(new UrlEncodedFormFactory());
private final DlcList dlcList;
private final File rootDirectory = new File("dlc");
private final UserManager userManager;
public DlsHandler(Entralinked entralinked) {
this.dlcList = entralinked.getDlcList();
this.userManager = entralinked.getUserManager();
}
@@ -45,11 +51,13 @@ public class DlsHandler implements HttpHandler {
private void handleDownloadRequest(Context ctx) throws IOException {
// Deserialize request body
DlsRequest request = mapper.readValue(ctx.body().replace("%2A", "*"), DlsRequest.class);
logger.debug("Received {}", request);
// Check if service session is valid
ServiceSession session = userManager.getServiceSession(request.serviceToken(), "dls1.nintendowifi.net");
if(session == null) {
logger.debug("Rejecting DLS request because the service session has expired");
ctx.status(HttpStatus.UNAUTHORIZED);
return;
}
@@ -58,6 +66,7 @@ public class DlsHandler implements HttpHandler {
HttpRequestHandler<DlsRequest> handler = switch(request.action()) {
case "list" -> this::handleRetrieveDlcList;
case "contents" -> this::handleRetrieveDlcContent;
case "count" -> this::handleRetrieveDlcCount;
default -> throw new IllegalArgumentException("Invalid POST request action: " + request.action());
};
@@ -74,16 +83,48 @@ public class DlsHandler implements HttpHandler {
private void handleRetrieveDlcList(DlsRequest request, Context ctx) throws IOException {
User user = ctx.attribute("user");
String gameCode = getDlcGameCode(request.dlcGameCode());
String type = getRegionlessDlcType(request.dlcType());
String type = getDlcType(request.attr1());
String attr2 = request.attr2();
List<File> files = null;
// If an overriding DLC is present, send the data for that instead.
if(user.hasDlcOverride(type)) {
ctx.result(dlcList.getDlcListString(List.of(user.getDlcOverride(type))));
return;
files = Arrays.asList(user.getDlcOverride(type));
} else {
// Get list of files in DLC directory
File directory = getDlcDirectory(gameCode, type);
files = directory.isDirectory() ? Arrays.asList(directory.listFiles()) : new ArrayList<>();
}
// TODO NOTE: I assume that in a conventional implementation, certain DLC attributes may be omitted from the request.
ctx.result(dlcList.getDlcListString(dlcList.getDlcList(gameCode, type, request.dlcIndex())));
if(attr2 != null) {
// PGL content attr2 hack
files = Arrays.asList(files.get(Integer.parseInt(attr2) - 1));
} else {
// Mystery Gift randomness
Collections.shuffle(files);
}
StringBuilder builder = new StringBuilder();
int count = Math.min(files.size(), request.num());
// Create DLC list string
for(int i = 0; i < count; i++) {
File file = files.get(i);
if(type == null) {
// Generation 4 Mystery Gift
builder.append("%s\t\t\t\t\t%s\r\n".formatted(file.getName(), 936));
} else if(type.equals("MYSTERY")) {
// Generation 5 Mystery Gift
String gameFlag = GameVersion.lookup(request.gameCode()).isVersion2() ? "F00000" : "300000";
builder.append("%s\t\t%s\t%s\t\t%s\r\n".formatted(file.getName(), type, gameFlag, 720));
} else {
// PGL content
builder.append("%s\t\t%s\t%s\t\t%s\r\n".formatted(file.getName(), type, i + 1, file.length()));
}
}
// Send result
ctx.result(builder.toString());
}
/**
@@ -92,33 +133,45 @@ public class DlsHandler implements HttpHandler {
private void handleRetrieveDlcContent(DlsRequest request, Context ctx) throws IOException {
User user = ctx.attribute("user");
String gameCode = getDlcGameCode(request.dlcGameCode());
String type = getRegionlessDlcType(request.dlcType());
Dlc dlc = user.hasDlcOverride(type) ? user.getDlcOverride(type) : dlcList.getDlc(gameCode, type, request.dlcName());
String type = getDlcType(request.attr1());
File file = user.hasDlcOverride(type) ? user.getDlcOverride(type) : type != null
? new File(rootDirectory, "%s/%s/%s".formatted(gameCode, type, request.dlcName()))
: new File(rootDirectory, "%s/%s".formatted(gameCode, request.dlcName()));
// Check if the requested DLC exists
if(dlc == null) {
if(!file.exists()) {
ctx.status(HttpStatus.NOT_FOUND);
return;
}
// Write DLC data
try(FileInputStream inputStream = new FileInputStream(dlc.path())) {
LEOutputStream outputStream = new LEOutputStream(ctx.outputStream());
inputStream.transferTo(outputStream);
// If checksum is not part of the file, manually append it
if(!dlc.checksumEmbedded()) {
outputStream.writeShort(dlc.checksum());
}
byte[] bytes = Files.readAllBytes(file.toPath());
if(type == null) {
// Generation 4 Mystery Gift
bytes = MysteryGiftUtility.createUniversalGiftData4(bytes, file.getName());
} else if(type.equals("MYSTERY")) {
// Generation 5 Mystery Gift
bytes = MysteryGiftUtility.createUniversalGiftData5(bytes);
}
// Send result
ctx.result(bytes);
}
/**
* POST handler for {@code /download action=count}
*/
private void handleRetrieveDlcCount(DlsRequest request, Context ctx) throws IOException {
ctx.result("1"); // TODO
}
/**
* @return The game serial that should be used for downloading DLC based on the provided input.
*/
private String getDlcGameCode(String gameCode) {
return switch(gameCode) {
case "IRAJ" -> "IRAO";
return switch(gameCode.substring(0, 3)) {
case "IRA" -> "IRAO"; // BW & B2W2
case "ADA", "CPU", "IPG" -> "ADAE"; // DPPt & HGSS
default -> gameCode;
};
}
@@ -126,13 +179,15 @@ public class DlsHandler implements HttpHandler {
/**
* @return The DLC type without the region identifier, or the input if it is an unknown type.
*/
private String getRegionlessDlcType(String dlcType) {
return switch(dlcType) {
case "CGEAR_E", "CGEAR_F", "CGEAR_I", "CGEAR_G", "CGEAR_S", "CGEAR_J", "CGEAR_K" -> "CGEAR";
case "CGEAR2_E", "CGEAR2_F", "CGEAR2_I", "CGEAR2_G", "CGEAR2_S", "CGEAR2_J", "CGEAR2_K" -> "CGEAR2";
case "ZUKAN_E", "ZUKAN_F", "ZUKAN_I", "ZUKAN_G", "ZUKAN_S", "ZUKAN_J", "ZUKAN_K" -> "ZUKAN";
case "MUSICAL_E", "MUSICAL_F", "MUSICAL_I", "MUSICAL_G", "MUSICAL_S", "MUSICAL_J", "MUSICAL_K" -> "MUSICAL";
default -> dlcType;
};
private String getDlcType(String attr1) {
if(attr1 == null || !attr1.contains("_")) {
return attr1;
}
return attr1.substring(0, attr1.lastIndexOf('_'));
}
private File getDlcDirectory(String gameCode, String dlcType) {
return dlcType == null ? new File(rootDirectory, gameCode) : new File(rootDirectory, "%s/%s".formatted(gameCode, dlcType));
}
}

View File

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

View File

@@ -2,6 +2,9 @@ package entralinked.network.http.nas;
import java.io.IOException;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
@@ -21,6 +24,7 @@ import io.javalin.http.Context;
*/
public class NasHandler implements HttpHandler {
private static final Logger logger = LogManager.getLogger();
private final ObjectMapper mapper = new ObjectMapper(new UrlEncodedFormFactory()).registerModule(new JavaTimeModule());
private final Configuration configuration;
private final UserManager userManager;
@@ -42,6 +46,7 @@ public class NasHandler implements HttpHandler {
private void handleNasRequest(Context ctx) throws IOException {
// Deserialize body into a request object
NasRequest request = mapper.readValue(ctx.body(), NasRequest.class);
logger.debug("Received {}", request);
// Determine handler function based on request action
HttpRequestHandler<NasRequest> handler = switch(request.action()) {
@@ -61,6 +66,7 @@ public class NasHandler implements HttpHandler {
private void handleLogin(NasRequest request, Context ctx) throws IOException {
// Make sure branch code is present
if(request.branchCode() == null) {
logger.debug("Rejecting NAS login request because no branch code is present");
result(ctx, NasReturnCode.BAD_REQUEST);
return;
}
@@ -77,7 +83,7 @@ public class NasHandler implements HttpHandler {
// Try to register, if the configuration allows it
if(!UserManager.isValidUserId(userId) || userManager.doesUserExist(userId)
|| !userManager.registerUser(userId, request.password())) {
|| userManager.registerUser(userId, request.password()) == null) {
// Oh well, try again!
result(ctx, NasReturnCode.USER_NOT_FOUND);
return;
@@ -85,10 +91,12 @@ public class NasHandler implements HttpHandler {
// Should *never* return null in this location
user = userManager.authenticateUser(userId, request.password());
logger.info("Created account for user {}", user.getRedactedId());
}
// Prepare GameSpy server credentials
ServiceCredentials credentials = userManager.createServiceSession(user, "gamespy", request.branchCode());
logger.info("Created GameSpy session for user {}", user.getRedactedId());
result(ctx, new NasLoginResponse("gamespy.com", credentials.authToken(), credentials.challenge()));
}
@@ -105,11 +113,14 @@ public class NasHandler implements HttpHandler {
}
// Try to register user
if(!userManager.registerUser(userId, request.password())) {
User user = userManager.registerUser(userId, request.password());
if(user == null) {
result(ctx, NasReturnCode.INTERNAL_SERVER_ERROR);
return;
}
logger.info("Created account for user {}", user.getRedactedId());
result(ctx, NasReturnCode.REGISTRATION_SUCCESS);
}
@@ -125,15 +136,19 @@ public class NasHandler implements HttpHandler {
return;
}
String type = request.serviceType();
// Determine service location from type
String service = switch(request.serviceType()) {
String service = switch(type) {
case "0000" -> "external"; // External game-specific service
case "9000" -> "dls1.nintendowifi.net"; // Download server
default -> throw new IllegalArgumentException("Invalid service type: " + request.serviceType());
default -> throw new IllegalArgumentException("Invalid service type: " + type);
};
// Prepare user credentials
ServiceCredentials credentials = userManager.createServiceSession(user, service, null);
logger.info("Created {} session for user {}",
type.equals("0000") ? "PGL" : type.equals("9000") ? "DLS1" : "this should never be logged", user.getRedactedId());
result(ctx, new NasServiceLocationResponse(true, service, credentials.authToken()));
}

View File

@@ -29,4 +29,11 @@ public record NasRequest(
// Request-specific info
@JsonProperty(value = "action", required = true) String action,
@JsonProperty("gsbrcd") String branchCode, // action=login
@JsonProperty("svc") String serviceType) {} // action=SVCLOC
@JsonProperty("svc") String serviceType) { // action=SVCLOC
@Override
public String toString() {
return ("NasRequest[gameCode=%s, makerCode=%s, unitCode=%s, sdkVersion=%s, language=%s, action=%s, serviceType=%s]")
.formatted(gameCode, makerCode, unitCode, sdkVersion, language, action, serviceType);
}
}

View File

@@ -1,5 +1,6 @@
package entralinked.network.http.pgl;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
@@ -16,7 +17,6 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import entralinked.Configuration;
import entralinked.Entralinked;
import entralinked.model.avenue.AvenueVisitor;
import entralinked.model.dlc.Dlc;
import entralinked.model.dlc.DlcList;
import entralinked.model.pkmn.PkmnInfo;
import entralinked.model.pkmn.PkmnInfoReader;
@@ -93,6 +93,7 @@ public class PglHandler implements HttpHandler {
if(credentials == null ||
!username.equals(credentials.getUsername()) || !password.equals(credentials.getPassword())) {
logger.debug("Rejecting PGL request because the auth credentials were incorrect");
ctx.status(HttpStatus.UNAUTHORIZED);
clearTasks(ctx);
return;
@@ -100,11 +101,13 @@ public class PglHandler implements HttpHandler {
// Deserialize the request
PglRequest request = mapper.readValue(ctx.queryString(), PglRequest.class);
logger.debug("Received {}", request);
// Verify the service session token
ServiceSession session = userManager.getServiceSession(request.token(), "external");
if(session == null) {
logger.debug("Rejecting PGL request because the service session has expired");
ctx.status(HttpStatus.UNAUTHORIZED);
clearTasks(ctx);
return;
@@ -131,7 +134,7 @@ public class PglHandler implements HttpHandler {
};
// Handle the request
handler.process(request, ctx);
handler.process(request, ctx);
}
/**
@@ -198,13 +201,14 @@ public class PglHandler implements HttpHandler {
return;
}
logger.info("Player {} is downloading save data as user {}", player.getGameSyncId(), user.getRedactedId());
// 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.
if(player.getStatus() == PlayerStatus.AWAKE) {
logger.info("Player {} is downloading save data, but is already awake!", player.getGameSyncId());
return;
}
@@ -221,8 +225,7 @@ public class PglHandler implements HttpHandler {
// Create or remove custom C-Gear skin DLC override
if("custom".equals(cgearSkin)) {
cgearSkinIndex = 1;
user.setDlcOverride(cgearType, new Dlc(player.getCGearSkinFile().getAbsolutePath(),
"custom", "IRAO", cgearType, cgearSkinIndex, 9730, 0, true));
user.setDlcOverride(cgearType, player.getCGearSkinFile());
} else {
cgearSkinIndex = dlcList.getDlcIndex("IRAO", cgearType, cgearSkin);
user.removeDlcOverride(cgearType);
@@ -231,8 +234,7 @@ public class PglHandler implements HttpHandler {
// Create or remove custom Pokédex skin DLC override
if("custom".equals(dexSkin)) {
dexSkinIndex = 1;
user.setDlcOverride("ZUKAN", new Dlc(player.getDexSkinFile().getAbsolutePath(),
"custom", "IRAO", "ZUKAN", dexSkinIndex, 25090, 0, true));
user.setDlcOverride("ZUKAN", player.getDexSkinFile());
} else {
dexSkinIndex = dlcList.getDlcIndex("IRAO", "ZUKAN", dexSkin);
user.removeDlcOverride("ZUKAN");
@@ -309,15 +311,15 @@ public class PglHandler implements HttpHandler {
byte[] nameBytes = visitor.name().getBytes(StandardCharsets.UTF_16LE);
outputStream.write(nameBytes, 0, Math.min(14, nameBytes.length));
outputStream.writeBytes(-1, 14 - nameBytes.length);
outputStream.writeShort(0xFF); // Null terminator
// Full visitor type consists of a trainer class and what I call a 'personality' index
// that, along with the trainer class, determines which phrases the visitor uses.
// The shope type is calculated in such an odd manner because for some reason,
// The shop type is calculated in such an odd manner because for some reason,
// the 'starting' index of the shop type used increases by 2 for each visitor type.
// 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
@@ -343,6 +345,7 @@ public class PglHandler implements HttpHandler {
private void handleMemoryLink(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 player exists
if(player == null) {
@@ -350,6 +353,12 @@ public class PglHandler implements HttpHandler {
return;
}
// Version null check because this can happen in specific cases
if(player.getGameVersion() == null) {
writeStatusCode(outputStream, 5); // No game save data exists for this Game Sync ID
return;
}
// Check if the save file belongs to Black or White
if(player.getGameVersion().isVersion2()) {
writeStatusCode(outputStream, 10); // Not a Black or White save
@@ -364,6 +373,8 @@ public class PglHandler implements HttpHandler {
return;
}
logger.info("User {} is Memory Linking with player {}", user.getRedactedId(), player.getGameSyncId());
// Send the save data!
try(FileInputStream inputStream = new FileInputStream(file)) {
writeStatusCode(outputStream, 0);
@@ -422,13 +433,15 @@ public class PglHandler implements HttpHandler {
* POST handler for {@code /dsio/gw?p=savedata.upload}
*/
private void handleUploadSaveData(PglRequest request, Context ctx) throws IOException {
// Prepare response
LEOutputStream outputStream = new LEOutputStream(ctx.outputStream());
// Check if the player exists and does not already have a Pokémon tucked in
Player player = playerManager.getPlayer(request.gameSyncId());
User user = ctx.attribute("user");
if(player == null || (player.getStatus() != PlayerStatus.AWAKE && !configuration.allowOverwritingPlayerDreamInfo())) {
// 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())) {
// Skip everything
ServletInputStream inputStream = ctx.req().getInputStream();
@@ -441,6 +454,8 @@ public class PglHandler implements HttpHandler {
return;
}
logger.info("Player {} is uploading save data as user {}", player.getGameSyncId(), user.getRedactedId());
// Try to store save data
if(!playerManager.storePlayerGameSaveFile(player, ctx.bodyInputStream())) {
writeStatusCode(outputStream, 4); // Game save data IO error
@@ -474,13 +489,9 @@ public class PglHandler implements HttpHandler {
/**
* POST handler for {@code /dsio/gw?p=account.create.upload}
*/
private void handleCreateAccount(PglRequest request, Context ctx) throws IOException {
// It sends the entire save file, but we just skip through it because we don't need anything from it here
ServletInputStream inputStream = ctx.req().getInputStream();
while(!inputStream.isFinished()) {
inputStream.read();
}
private void handleCreateAccount(PglRequest request, Context ctx) throws IOException {
// Have to read all the bytes first for some reason
byte[] bytes = ctx.bodyAsBytes();
// Prepare response
LEOutputStream outputStream = new LEOutputStream(ctx.outputStream());
@@ -498,11 +509,19 @@ public class PglHandler implements HttpHandler {
}
// Try to register player
if(playerManager.registerPlayer(request.gameSyncId()) == null) {
Player player = playerManager.registerPlayer(request.gameSyncId(), request.gameVersion());
if(player == null) {
writeStatusCode(outputStream, 3); // Registration error
return;
}
// Try to store save data
if(!playerManager.storePlayerGameSaveFile(player, new ByteArrayInputStream(bytes))) {
writeStatusCode(outputStream, 4); // Game save data IO error
return;
}
// Write status code
writeStatusCode(outputStream, 0);
}
@@ -523,7 +542,8 @@ public class PglHandler implements HttpHandler {
}
// Try to register player
if(playerManager.registerPlayer(gameSyncId) == null) {
// Regrettably, this request does not contain game save & version data.
if(playerManager.registerPlayer(gameSyncId, null) == null) {
writeStatusCode(outputStream, 3); // Registration error
return;
}

View File

@@ -17,4 +17,11 @@ public record PglRequest(
public GameVersion gameVersion() {
return GameVersion.lookup(romCode(), languageCode());
}
@Override
public String toString() {
// Exlude token
return "PglRequest[gameSyncId=%s, type=%s, romCode=%s, languageCode=%s, dreamWorld=%s]"
.formatted(gameSyncId, type, romCode, languageCode, dreamWorld);
}
}

View File

@@ -55,7 +55,6 @@ public class CertificateGenerator {
+ "Fw2ewOxmIyw3d4HFGrLx9yvk6Q5HpTlSgvbgO22k/vo0N43dO7utgfn/e4Xo9alIAktnewGqj5nRm75H7iaLEJBo983jJdugeXdp6y5X6sDmaDmyw/p91TKPrKmD"
+ "4ebbTY38D5WT67fONjW/X1OLNHg46GYYZa6etbxQ7671f7GypgwnI=";
private static final String issuerPrivateKeyString =
"eNod0Em2a0AAANAFGRBNYVgK0QalfWaIPproZfX/nH+XcG1dR4mnSxCaUu3JKUe5WLR1rND+Q+gwb3NO3ws5e0YXcydZcUtNV/35votzw9SsDstssI0TPxg5q1XPUq"
+ "EpGgfib4UnATQKiAcZHsBOsEWg2nShyhd7CoLQznBPWW/+iLfW3bMujf723htqG+n0p30h/SClZIRZibH7I5hGgQHRqgvpuJ/IDWpH9HQZelCC0xPK8uXZ87jcwv"

View File

@@ -29,9 +29,9 @@ public class ColorUtility {
*/
public static int convertBGR555ToRGB888(int color) {
int red = (color & 0x1F) << 3;
int green = ((color & 0x3E0) >> 5) << 3;
int blue = ((color & 0x7C00) >> 10) << 3;
return (red << 16) | (green << 8) | blue;
int green = (color >> 5 & 0x1F) << 3;
int blue = (color >> 10 & 0x1F) << 3;
return ((red | red >> 5) << 16) | ((green | green >> 5) << 8) | (blue | blue >> 5);
}
/**

View File

@@ -7,6 +7,7 @@ import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
import org.apache.logging.log4j.Level;
import org.apache.logging.log4j.core.Appender;
import org.apache.logging.log4j.core.Core;
import org.apache.logging.log4j.core.Filter;
@@ -25,7 +26,8 @@ import org.apache.logging.log4j.core.config.plugins.PluginFactory;
printObject = true)
public class ConsumerAppender extends AbstractAppender {
protected static final Map<String, List<Consumer<String>>> consumerMap = new ConcurrentHashMap<>();
public record LogMessage(Level level, String rawMessage, String formattedMessage) {}
protected static final Map<String, List<Consumer<LogMessage>>> consumerMap = new ConcurrentHashMap<>();
protected ConsumerAppender(String name, Filter filter, Layout<? extends Serializable> layout,
boolean ignoreExceptions, Property[] properties) {
@@ -41,8 +43,8 @@ public class ConsumerAppender extends AbstractAppender {
return new ConsumerAppender(name, filter, layout, ignoreExceptions, null);
}
public static void addConsumer(String appenderName, Consumer<String> consumer) {
List<Consumer<String>> consumers = consumerMap.getOrDefault(appenderName, new ArrayList<>());
public static void addConsumer(String appenderName, Consumer<LogMessage> consumer) {
List<Consumer<LogMessage>> consumers = consumerMap.getOrDefault(appenderName, new ArrayList<>());
consumers.add(consumer);
consumerMap.putIfAbsent(appenderName, consumers);
}
@@ -50,11 +52,12 @@ public class ConsumerAppender extends AbstractAppender {
@Override
public void append(LogEvent event) {
String formattedMessage = getLayout().toSerializable(event).toString();
List<Consumer<String>> consumers = consumerMap.get(getName());
List<Consumer<LogMessage>> consumers = consumerMap.get(getName());
LogMessage logMessage = new LogMessage(event.getLevel(), event.getMessage().getFormattedMessage(), formattedMessage);
if(consumers != null) {
for(Consumer<String> consumer : consumers) {
consumer.accept(formattedMessage);
for(Consumer<LogMessage> consumer : consumers) {
consumer.accept(logMessage);
}
}
}

View File

@@ -1,6 +1,9 @@
package entralinked.utility;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
@@ -18,9 +21,23 @@ public class MD5 {
private static MessageDigest digest;
/**
* @return A hex-formatted MD5 hash of the specified input.
* @return A hex-formatted MD5 hash of the specified input string.
*/
public static String digest(String string) {
return StringUtil.toHexStringPadded(digest(string.getBytes(StandardCharsets.ISO_8859_1)));
}
/**
* @return A hex-formatted MD5 hash of the specified input file.
*/
public static String digest(File file) throws IOException {
return StringUtil.toHexStringPadded(digest(Files.readAllBytes(file.toPath())));
}
/**
* @return An MD5 hash of the specified input.
*/
public static byte[] digest(byte[] bytes) {
if(digest == null) {
try {
digest = MessageDigest.getInstance("MD5");
@@ -29,6 +46,6 @@ public class MD5 {
}
}
return StringUtil.toHexStringPadded(digest.digest(string.getBytes(StandardCharsets.ISO_8859_1)));
return digest.digest(bytes);
}
}

View File

@@ -0,0 +1,94 @@
package entralinked.utility;
import java.nio.charset.StandardCharsets;
import org.bouncycastle.util.Arrays;
/**
* Haphazardly thrown-together utility for generating DLS Mystery Gift data & removing version/language locks.
*/
public class MysteryGiftUtility {
public static final String DPPGS_CHARTABLE = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyzÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿŒœŞşªºááá$¡¿!?,.…·/'“”„«»()♂♀+-*#=&~:;♠♣♥♦★◉○□△◇@♪%ááááááááááá ";
private static byte[] encodeDPPGS(String string) {
int length = string.length();
byte[] bytes = new byte[length * 2];
for(int i = 0; i < length; i++) {
char character = string.charAt(i);
int encoded = character == '\n' ? 0xE000 : DPPGS_CHARTABLE.indexOf(character) + 0x121;
if(encoded == 0x120) {
encoded = 0x1DE;
}
bytes[i * 2] = (byte)(encoded & 0xFF);
bytes[i * 2 + 1] = (byte)((encoded >> 8) & 0xFF);
}
return bytes;
}
public static byte[] createUniversalGiftData4(byte[] bytes) {
return createUniversalGiftData4(bytes, "Here's your Mystery Gift.\nEnjoy!");
}
public static byte[] createUniversalGiftData4(byte[] bytes, String title) {
// Check data size
if(bytes.length > 936) {
throw new IllegalArgumentException("Data too large: %s".formatted(bytes.length));
}
byte[] result = new byte[936];
if(bytes.length <= 856) {
// Create gift title data
System.arraycopy(bytes, 0x00, result, 0x50, bytes.length);
Arrays.fill(result, 0x00, 0x48, (byte)0xFF);
byte[] titleBytes = encodeDPPGS(title);
System.arraycopy(titleBytes, 0, result, 0, Math.min(0x48, titleBytes.length));
// Wonder card index (prevents duplicate redemptions)
int id = Crc16.calc(result, 0x00, 0x3A8); // Let's just use the checksum for now
result[0x4C] = (byte)(id & 0xFF);
result[0x4D] = (byte)((id >> 8) & 0xFF);
// Wonder card present flag?
result[0x4E] = (byte)(bytes.length == 0x358 ? 0x0D : 0x00);
} else {
System.arraycopy(bytes, 0, result, 0, bytes.length);
}
// Clear game version
result[0x48] = 0;
result[0x49] = 0;
return result;
}
public static byte[] createUniversalGiftData5(byte[] bytes) {
// Check data size
if(bytes.length > 720) {
throw new IllegalArgumentException("Data too large: %s".formatted(bytes.length));
}
byte[] result = new byte[720];
System.arraycopy(bytes, 0, result, 0, bytes.length);
result[0xCE] = 0; // Version flag
result[0x2CB] = 0; // Language code
// Create standard gift description if there is none
if(bytes.length == 204) {
Arrays.fill(result, 0xD0, 0x2CA, (byte)0xFF);
String description = "No description is available for this gift.";
byte[] descriptionBytes = description.replace('\n', '\uFFFE').getBytes(StandardCharsets.UTF_16LE);
System.arraycopy(descriptionBytes, 0, result, 0xD0, Math.min(0x1FA, descriptionBytes.length));
}
// Recalculate checksum
int checksum = Crc16.calc(result, 0, 0x2CE);
result[0x2CE] = (byte)(checksum & 0xFF);
result[0x2CF] = (byte)((checksum >> 8) & 0xFF);
return result;
}
}

View File

@@ -0,0 +1,58 @@
package entralinked.utility;
import java.awt.GridBagConstraints;
import java.awt.event.ActionEvent;
import javax.swing.AbstractAction;
import javax.swing.Action;
import javax.swing.Icon;
public class SwingUtility {
@SuppressWarnings("serial")
public static Action createAction(String name, Icon icon, Runnable handler) {
AbstractAction action = new AbstractAction(name, icon) {
@Override
public void actionPerformed(ActionEvent event) {
handler.run();
}
};
if(icon != null) {
action.putValue(Action.SHORT_DESCRIPTION, name);
}
return action;
}
public static Action createAction(String name, Runnable handler) {
return createAction(name, null, handler);
}
public static GridBagConstraints createConstraints(int x, int y) {
return createConstraints(x, y, 1, 1);
}
public static GridBagConstraints createConstraints(int x, int y, int width, int height) {
return createConstraints(x, y, width, height, 1, 1);
}
public static GridBagConstraints createConstraints(int x, int y, int width, int height, double weightX, double weightY) {
return createConstraints(x, y, width, height, weightX, weightY, 8, 8);
}
public static GridBagConstraints createConstraints(int x, int y, int width, int height, double weightX, double weightY,
int paddingX, int paddingY) {
GridBagConstraints constraints = new GridBagConstraints();
constraints.fill = GridBagConstraints.BOTH;
constraints.gridx = x;
constraints.gridy = y;
constraints.gridwidth = width;
constraints.gridheight = height;
constraints.weightx = weightX;
constraints.weighty = weightY;
constraints.ipadx = paddingX;
constraints.ipady = paddingY;
return constraints;
}
}

View File

@@ -128,7 +128,12 @@ public class TiledImageUtility {
// In cases where background colors are present, pixels that use the *first* foreground color
// will be replaced by the background color at that pixel's location.
for(int i = 0; i < COLOR_PALETTE_SIZE; i++) {
colorPalette[i] = ColorUtility.convertBGR555ToRGB888(inputStream.readShort());
int color = inputStream.readShort();
// The game seems to always replace the first color of the foreground with the background,
// so let's just set it to black so that C-Gear skin previews are more accurate.
// This won't do anything for Pokédex skins, as they use a special background color palette.
colorPalette[i] = i == 0 ? 0 : ColorUtility.convertBGR555ToRGB888(color);
}
// Read background color data.
@@ -153,7 +158,7 @@ public class TiledImageUtility {
int flipBits = rightBits & 12;
// The normalized tile index is the index of the in-memory tile index in the lookup table.
int tileIndex = 0;
int tileIndex = -1;
for(int k = 0; k < tileIndices.length; k++) {
if(memoryIndex == tileIndices[k]) {
@@ -162,6 +167,12 @@ public class TiledImageUtility {
}
}
// If no tile could be found, then just skip it.
// TODO It is possible that it uses background colors in this case, but just leaving the tile black should be good enough.
if(tileIndex == -1) {
continue;
}
// Apply the pixels of this tile to the resulting image.
for(int j = 0; j < TILE_SIZE; j++) {
// Get the color index for this pixel based on how the tile is flipped.

View File

@@ -6,11 +6,14 @@
<body>
<div class="root-container">
<div>
<label for="gsid">Game Sync ID</label><br>
<input type='text' id="gsid" name='gsid' placeholder='XXXXXXXXXX'>
<button id="login" onclick="postLogin()">Login</button>
<form onsubmit="return postLogin()">
<label for="gsid">Game Sync ID</label><br>
<input type="text" id="gsid" name="gsid" placeholder="XXXXXXXXXX" autocomplete="on">
<label for="submit" class="big-button">Login</label>
<input type="submit" id="submit" hidden/>
</form>
</div>
</div>
</div>
</body>
<script src="scripts/utility.js"></script>
<script src="scripts/login.js"></script>

View File

@@ -4,7 +4,7 @@
<link rel="stylesheet" href="styles/profile.css">
</head>
<body>
<div id="main-container" class="root-container" style="display:none;">
<div id="main-container" class="centered-container" style="display:none;">
<div>
<div>
<label id="game-summary" class="header-text"></label>
@@ -127,21 +127,21 @@
<div class="grid-container">
<div>
<label>C-Gear Skin | </label><a href="#" onclick="return previewSkin('cgear-skin', 'CGEAR')">Preview</a>
<select id="cgear-skin" style="margin-bottom:0px;">
<select id="cgear-skin">
<option value="none">Do not change</option>
<option disabled>──────────────────────</option>
</select>
<button onclick="openFileChooser('cgear-file-input')">Upload Custom</button>
<input id="cgear-file-input" type="file" accept="image/*" onchange="uploadSkin('CGEAR', this.files[0])" onclick="this.value = null;" hidden/>
<input id="cgear-file-input" type="file" accept=".png, .bmp, .jpg, .jpeg" onchange="uploadSkin('CGEAR', this.files[0])" onclick="this.value = null;" hidden/>
</div>
<div>
<label>Pokédex Skin | </label><a href="#" onclick="return previewSkin('dex-skin', 'ZUKAN')">Preview</a>
<select id="dex-skin" style="margin-bottom:0px;">
<select id="dex-skin">
<option value="none">Do not change</option>
<option disabled>──────────────────────</option>
</select>
<button onclick="openFileChooser('dex-file-input')">Upload Custom</button>
<input id="dex-file-input" type="file" accept="image/*" onchange="uploadSkin('ZUKAN', this.files[0])" onclick="this.value = null;" hidden/>
<input id="dex-file-input" type="file" accept=".png, .bmp, .jpg, .jpeg" onchange="uploadSkin('ZUKAN', this.files[0])" onclick="this.value = null;" hidden/>
</div>
<div>
<label>Musical Show</label>
@@ -164,122 +164,144 @@
</div>
<!-- Entree Forest Encounter Configuration Form -->
<div id="encounter-config" class="popup">
<div class="content">
<button class="close-button" onclick="closeEncounterForm()">X</button>
<form id="encounter-form">
<label for="encounter-form-species">Species</label>
<select id="encounter-form-species" name="species" value="1">
<!-- Filled by profile.js -->
</select>
<label for="encounter-form-move">Special Move</label>
<select id="encounter-form-move" name="move" value="0">
<option value="0">None</option>
<!-- Filled by profile.js -->
</select>
<label for="encounter-form-form">Form</label>
<select id="encounter-form-form" name="form" value="0">
<option value="0">N/A</option>
<!-- Filled by profile.js -->
</select>
<label for="encounter-form-gender">Gender</label>
<select id="encounter-form-gender" name="gender" value="GENDERLESS">
<option value="MALE">Male</option>
<option value="FEMALE">Female</option>
<option value="GENDERLESS">Random</option>
</select>
<label for="encounter-form-animation">Animation</label>
<select id="encounter-form-animation" name="animation">
<option value="LOOK_AROUND">Look Around</option>
<option value="WALK_AROUND">Walk Around</option>
<option value="WALK_LOOK_AROUND">Walk and Look Around</option>
<option value="WALK_VERTICALLY">Walk Vertically</option>
<option value="WALK_HORIZONTALLY">Walk Horizontally</option>
<option value="WALK_HORIZONTALLY_LOOK_AROUND">Walk Horizontally and Look Around</option>
<option value="SPIN_RIGHT">Spin Right</option>
<option value="SPIN_LEFT">Spin Left</option>
</select>
</form>
<button class="big-button" onclick="saveEncounter()">Confirm</button>
<button class="big-button" onclick="removeEncounter()">Remove</button>
<div class="centered-container">
<div class="content">
<button class="close-button" onclick="closeEncounterForm()">X</button>
<form id="encounter-form">
<label for="encounter-form-species">Species</label>
<select id="encounter-form-species" name="species" value="1">
<!-- Filled by profile.js -->
</select>
<label for="encounter-form-move">Special Move</label>
<select id="encounter-form-move" name="move" value="0">
<option value="0">None</option>
<!-- Filled by profile.js -->
</select>
<label for="encounter-form-form">Form</label>
<select id="encounter-form-form" name="form" value="0">
<option value="0">N/A</option>
<!-- Filled by profile.js -->
</select>
<label for="encounter-form-gender">Gender</label>
<select id="encounter-form-gender" name="gender" value="GENDERLESS">
<option value="MALE">Male</option>
<option value="FEMALE">Female</option>
<option value="GENDERLESS">Random</option>
</select>
<label for="encounter-form-animation">Animation</label>
<select id="encounter-form-animation" name="animation">
<option value="LOOK_AROUND">Look Around</option>
<option value="WALK_AROUND">Walk Around</option>
<option value="WALK_LOOK_AROUND">Walk and Look Around</option>
<option value="WALK_VERTICALLY">Walk Vertically</option>
<option value="WALK_HORIZONTALLY">Walk Horizontally</option>
<option value="WALK_LOOK_HORIZONTALLY">Walk Horizontally and Look Around</option>
<option value="SPIN_RIGHT">Spin Right</option>
<option value="SPIN_LEFT">Spin Left</option>
</select>
</form>
<button class="big-button" onclick="saveEncounter()">Confirm</button>
<button class="big-button" onclick="removeEncounter()">Remove</button>
</div>
</div>
</div>
<!-- Item Configuration Form -->
<div id="item-config" class="popup">
<div class="content">
<button class="close-button" onclick="closeItemForm()">X</button>
<form id="item-form">
<label for="item-form-id">Item</label>
<select id="item-form-id" name="id" value="1">
<!-- Filled by profile.js -->
</select>
<label for="item-form-quantity">Quantity</label>
<input id="item-form-quantity" name="quantity" type="number" value="1" min="1" max="20"/>
</form>
<button class="big-button" onclick="saveItem()">Confirm</button>
<button class="big-button" onclick="removeItem()">Remove</button>
<div class="centered-container">
<div class="content">
<button class="close-button" onclick="closeItemForm()">X</button>
<form id="item-form">
<label for="item-form-id">Item</label>
<select id="item-form-id" name="id" value="1">
<!-- Filled by profile.js -->
</select>
<label for="item-form-quantity">Quantity</label>
<input id="item-form-quantity" name="quantity" type="number" value="1" min="1" max="20"/>
</form>
<button class="big-button" onclick="saveItem()">Confirm</button>
<button class="big-button" onclick="removeItem()">Remove</button>
</div>
</div>
</div>
<!-- Join Avenue Visitor Configuration Form -->
<div id="visitor-config" class="popup">
<div class="content">
<button class="close-button" onclick="closeVisitorForm()">X</button>
<form id="visitor-form">
<label for="visitor-form-name">Name (Max. 7 characters)</label>
<input id="visitor-form-name" name="name" placeholder="Trainer" maxlength="7"/>
<label for="visitor-form-type">Trainer Class</label>
<select id="visitor-form-type" name="type" value="ACE_TRAINER_MALE">
<option value="YOUNGSTER">Youngster</option>
<option value="LASS">Lass</option>
<option value="ACE_TRAINER_MALE">Ace Trainer (Male)</option>
<option value="ACE_TRAINER_FEMALE">Ace Trainer (Female)</option>
<option value="RANGER_MALE">Pokémon Ranger (Male)</option>
<option value="RANGER_FEMALE">Pokémon Ranger (Female)</option>
<option value="BREEDER_MALE">Pokémon Breeder (Male)</option>
<option value="BREEDER_FEMALE">Pokémon Breeder (Female)</option>
<option value="SCIENTIST_MALE">Scientist (Male)</option>
<option value="SCIENTIST_FEMALE">Scientist (Female)</option>
<option value="HIKER">Hiker</option>
<option value="PARASOL_LADY">Parasol Lady</option>
<option value="ROUGHNECK">Roughneck</option>
<option value="NURSE">Nurse</option>
<option value="PRESCHOOLER_MALE">Preschooler (Male)</option>
<option value="PRESCHOOLER_FEMALE">Preschooler (Female)</option>
</select>
<label for="visitor-form-shop-type">Shop Type</label>
<select id="visitor-form-shop-type" name="shopType" value="RAFFLE">
<option value="RAFFLE">Raffle Shop</option>
<option value="FLORIST">Flower Shop</option>
<option value="SALON">Beauty Salon</option>
<option value="ANTIQUE">Antique Shop</option>
<option value="DOJO">Training Dojo</option>
<option value="CAFE">Café</option>
<option value="MARKET">Market</option>
</select>
<label for="visitor-form-game">Game of Origin (Affects which goods/services are sold)</label>
<select id="visitor-form-game" name="gameVersion" value="BLACK_ENGLISH">
<option value="BLACK_ENGLISH">Black Version</option>
<option value="WHITE_ENGLISH">White Version</option>
<option value="BLACK_2_ENGLISH">Black Version 2</option>
<option value="WHITE_2_ENGLISH">White Version 2</option>
</select>
<label for="visitor-form-region">Country</label>
<select id="visitor-form-region" name="region" value="1">
<!-- Filled by profile.js -->
</select>
<label for="visitor-form-subregion">State/Province</label>
<select id="visitor-form-subregion" name="subregion" value="0">
<option value="0">N/A</option>
<!-- Filled by profile.js -->
</select>
<label for="visitor-form-personality">Personality (Affects phrases used)</label>
<input id="visitor-form-personality" name="personality" type="number" value="0" min="0" max="7"/>
<label for="visitor-form-dreamer">Tucked-in Pokémon Species</label>
<select id="visitor-form-dreamer" name="dreamerSpecies" value="1">
<!-- Filled by profile.js -->
</select>
</form>
<button class="big-button" onclick="saveVisitor()">Confirm</button>
<button class="big-button" onclick="removeVisitor()">Remove</button>
<div class="centered-container">
<div class="content">
<button class="close-button" onclick="closeVisitorForm()">X</button>
<form id="visitor-form">
<label for="visitor-form-name">Name (Max. 7 characters)</label>
<input id="visitor-form-name" type="text" name="name" placeholder="Trainer" maxlength="7"/>
<label for="visitor-form-type">Trainer Class</label>
<select id="visitor-form-type" name="type" value="ACE_TRAINER_MALE">
<option value="YOUNGSTER">Youngster</option>
<option value="LASS">Lass</option>
<option value="ACE_TRAINER_MALE">Ace Trainer (Male)</option>
<option value="ACE_TRAINER_FEMALE">Ace Trainer (Female)</option>
<option value="RANGER_MALE">Pokémon Ranger (Male)</option>
<option value="RANGER_FEMALE">Pokémon Ranger (Female)</option>
<option value="BREEDER_MALE">Pokémon Breeder (Male)</option>
<option value="BREEDER_FEMALE">Pokémon Breeder (Female)</option>
<option value="SCIENTIST_MALE">Scientist (Male)</option>
<option value="SCIENTIST_FEMALE">Scientist (Female)</option>
<option value="HIKER">Hiker</option>
<option value="PARASOL_LADY">Parasol Lady</option>
<option value="ROUGHNECK">Roughneck</option>
<option value="NURSE">Nurse</option>
<option value="PRESCHOOLER_MALE">Preschooler (Male)</option>
<option value="PRESCHOOLER_FEMALE">Preschooler (Female)</option>
</select>
<label for="visitor-form-shop-type">Shop Type</label>
<select id="visitor-form-shop-type" name="shopType" value="RAFFLE">
<option value="RAFFLE">Raffle Shop</option>
<option value="FLORIST">Flower Shop</option>
<option value="SALON">Beauty Salon</option>
<option value="ANTIQUE">Antique Shop</option>
<option value="DOJO">Training Dojo</option>
<option value="CAFE">Café</option>
<option value="MARKET">Market</option>
</select>
<label for="visitor-form-game">Game of Origin (Affects which goods/services are sold)</label>
<select id="visitor-form-game" name="gameVersion" value="BLACK_ENGLISH">
<option value="BLACK_ENGLISH">Black Version</option>
<option value="WHITE_ENGLISH">White Version</option>
<option value="BLACK_2_ENGLISH">Black Version 2</option>
<option value="WHITE_2_ENGLISH">White Version 2</option>
</select>
<label for="visitor-form-region">Country</label>
<select id="visitor-form-region" name="region" value="1">
<!-- Filled by profile.js -->
</select>
<label for="visitor-form-subregion">State/Province</label>
<select id="visitor-form-subregion" name="subregion" value="0">
<option value="0">N/A</option>
<!-- Filled by profile.js -->
</select>
<label for="visitor-form-personality">Personality (Affects phrases used)</label>
<input id="visitor-form-personality" name="personality" type="number" value="0" min="0" max="7"/>
<label for="visitor-form-dreamer">Tucked-in Pokémon Species</label>
<select id="visitor-form-dreamer" name="dreamerSpecies" value="1">
<!-- Filled by profile.js -->
</select>
</form>
<button class="big-button" onclick="saveVisitor()">Confirm</button>
<button class="big-button" onclick="removeVisitor()">Remove</button>
</div>
</div>
</div>
<div id="skin-preview" class="popup">
<div class="centered-container">
<div id="skin-preview-content" class="content">
<button class="close-button" onclick="closeSkinPreview()">X</button>
<label>Skin Preview</label><br>
<img id="skin-preview-image" width=256 height=192/>
<div>
<label>Resolution</label><br>
<input id="resolution-x1" type="radio" name="resolution" onclick="setSkinPreviewResolution(256, 192);" checked/>
<label for="resolution-x1">x1</label>
<input id="resolution-x2" type="radio" name="resolution" onclick="setSkinPreviewResolution(512, 384);"/>
<label for="resolution-x2">x2</label>
</div>
</div>
</div>
</div>
</body>

View File

@@ -10,4 +10,6 @@ function postLogin() {
window.location.href = "/dashboard/profile.html";
}
});
return false;
}

View File

@@ -509,7 +509,7 @@ const REGION_LIST = [];
{id: 493, name: "Arceus", downloadable: true, gender: "unknown", forms: [
"Normal", "Fighting", "Flying", "Poison", "Ground", "Rock", "Bug", "Ghost",
"Steel", "Fire", "Water", "Grass", "Electric", "Psychic", "Ice", "Dragon", "Dark"]},
{id: 494, name: "Victini", downloadable: true, gender: "unknown"},
{id: 494, name: "Victini", downloadable: false, gender: "unknown"},
{id: 495, name: "Snivy", downloadable: false},
{id: 496, name: "Servine", downloadable: false},
{id: 497, name: "Serperior", downloadable: false},

View File

@@ -32,6 +32,9 @@ const ELEMENT_VISITOR_SUBREGION = document.getElementById("visitor-form-subregio
const ELEMENT_VISITOR_PERSONALITY = document.getElementById("visitor-form-personality");
const ELEMENT_VISITOR_DREAMER = document.getElementById("visitor-form-dreamer");
const ELEMENT_SKIN_PREVIEW = document.getElementById("skin-preview");
const ELEMENT_SKIN_PREVIEW_IMAGE = document.getElementById("skin-preview-image");
const ELEMENT_CGEAR_SKIN_INPUT = document.getElementById("cgear-skin");
const ELEMENT_DEX_SKIN_INPUT = document.getElementById("dex-skin");
const ELEMENT_MUSICAL_INPUT = document.getElementById("musical");
@@ -532,10 +535,21 @@ function previewSkin(inputElementId, type) {
type = "CGEAR2";
}
window.open("/dashboard/previewskin?type=" + type + "&name=" + value);
let revisionParam = value == "custom" ? "&revision=" + new Date().getTime() : ""; // Hack to work around the browser cache
ELEMENT_SKIN_PREVIEW_IMAGE.src = "/dashboard/previewskin?type=" + type + "&name=" + value + revisionParam;
ELEMENT_SKIN_PREVIEW.style.display = "block";
return false;
}
function setSkinPreviewResolution(width, height) {
ELEMENT_SKIN_PREVIEW_IMAGE.width = width;
ELEMENT_SKIN_PREVIEW_IMAGE.height = height;
}
function closeSkinPreview() {
ELEMENT_SKIN_PREVIEW.style.display = "none";
}
function fetchDlcData() {
// Fetch C-Gear skins
fetchData("GET", "/dashboard/dlc?type=" + (isVersion2() ? "CGEAR2" : "CGEAR")).then((response) => {

View File

@@ -11,21 +11,22 @@ input {
border-radius: 10px;
width: 100%;
margin-top: 4px;
margin-bottom: 16px;
margin-bottom: 24px;
padding: 4px 4px;
font-size: 16px;
}
button {
.big-button {
color: white;
background-color: #3D3D3D;
border: 0px;
border-radius: 10px;
font-size: 16px;
font-family: sans-serif;
padding: 16px 32px;
}
button:active {
.big-button:active {
background-color: #333333;
}

View File

@@ -12,12 +12,15 @@ img {
image-rendering: pixelated;
}
input, select {
color: white;
background-color: #262626;
input[type=text], input[type=number], select {
border: 0px;
border-radius: 10px;
width: 100%;
}
input, select {
color: white;
background-color: #262626;
margin-top: 4px;
margin-bottom: 16px;
padding: 4px 4px;
@@ -39,14 +42,14 @@ a:link, a:visited {
color: white;
}
.root-container {
.centered-container {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.root-container div:first-child {
.centered-container div:first-child {
max-width: 532px;
margin: auto;
}
@@ -87,12 +90,6 @@ a:link, a:visited {
padding-left: 5px;
}
.dreamer-summary td#dreamer-sprite {
width: 96px;
text-align: center;
padding-left: 0px;
}
.header-text {
text-align: center;
font-weight: bold;
@@ -110,21 +107,18 @@ a:link, a:visited {
background: rgba(0, 0, 0, 0.6);
}
.popup .close-button {
position: absolute;
background-color: #3D3D3D;
top: 10px;
right: 10px;
.content {
position: relative;
background-color: #191919;
width: 500px;
padding: 20px 20px 0px 20px;
}
.popup .content {
background-color: #191919;
.close-button {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
padding: 20px;
width: 500px;
top: 8px;
right: 8px;
background-color: #3D3D3D;
}
.encounter-image-table {
@@ -187,5 +181,25 @@ a:link, a:visited {
.big-button {
font-size: 16px;
padding: 16px 32px;
margin-bottom: 8px;
margin-bottom: 16px;
}
#cgear-skin, #dex-skin {
margin-bottom: 0px;
}
#dreamer-sprite {
width: 96px;
text-align: center;
padding-left: 0px;
}
#skin-preview-content {
width: auto;
}
#skin-preview-image {
box-shadow: 0px 0px 10px 3px #0A0A0A;
margin-top: 4px;
margin-bottom: 12px;
}

View File

@@ -1,59 +1,62 @@
/dlc/IRAO/CGEAR/01 - Default.bin
/dlc/IRAO/CGEAR/02 - Meadow Munna.bin
/dlc/IRAO/CGEAR/03 - Aim for the Top.bin
/dlc/IRAO/CGEAR/04 - Twinkle Minccino.bin
/dlc/IRAO/CGEAR/05 - PokeSma Purrloin.bin
/dlc/IRAO/CGEAR/06 - Earful Audino.bin
/dlc/IRAO/CGEAR/07 - Happy Piplup.bin
/dlc/IRAO/CGEAR/08 - Poison Jab Croagunk.bin
/dlc/IRAO/CGEAR/09 - Venusaur!.bin
/dlc/IRAO/CGEAR/10 - Charizard!.bin
/dlc/IRAO/CGEAR/11 - Blastoise!.bin
/dlc/IRAO/CGEAR/12 - V for Victory!.bin
/dlc/IRAO/CGEAR/13 - Virtual Pokemon.bin
/dlc/IRAO/CGEAR/14 - Pokemon Cafe.bin
/dlc/IRAO/CGEAR/15 - Hero Reshiram.bin
/dlc/IRAO/CGEAR/16 - Hero Zekrom.bin
/dlc/IRAO/CGEAR/17 - 2011 Worlds C-Gear.bin
/dlc/IRAO/CGEAR/18 - Zoroark!.bin
/dlc/IRAO/CGEAR/19 - Spring Deerling.bin
/dlc/IRAO/CGEAR/20 - Summer Deerling.bin
/dlc/IRAO/CGEAR/21 - Autumn Deerling.bin
/dlc/IRAO/CGEAR/22 - Winter Deerling.bin
/dlc/IRAO/CGEAR/23 - CRUSTLE!.bin
/dlc/IRAO/CGEAR/24 - KLINK!.bin
/dlc/IRAO/CGEAR/25 - Ducklett Friends.bin
/dlc/IRAO/CGEAR/26 - Guidance Cobalion.bin
/dlc/IRAO/CGEAR/27 - Trial Terrakion.bin
/dlc/IRAO/CGEAR/28 - Rumination Virizion.bin
/dlc/IRAO/CGEAR/29 - Keldeo Ordinary Forme.bin
/dlc/IRAO/CGEAR/30 - Aria of the Night Sky.bin
/dlc/IRAO/CGEAR/31 - 2012 Worlds C-Gear.bin
/dlc/IRAO/CGEAR/32 - Red Genesect.bin
/dlc/IRAO/CGEAR2/01 - Default.bin
/dlc/IRAO/CGEAR2/02 - Meadow Munna.bin
/dlc/IRAO/CGEAR2/03 - Keldeo Resolute Forme.bin
/dlc/IRAO/CGEAR2/04 - 2012 Worlds C-Gear.bin
/dlc/IRAO/CGEAR2/05 - Pumpkin Pikachu.bin
/dlc/IRAO/CGEAR2/06 - V for Victory!.bin
/dlc/IRAO/CGEAR2/07 - Hero Reshiram.bin
/dlc/IRAO/CGEAR2/08 - Hero Zekrom.bin
/dlc/IRAO/CGEAR2/09 - Aria of the Night Sky.bin
/dlc/IRAO/CGEAR2/10 - Sleeping Eevee.bin
/dlc/IRAO/CGEAR2/11 - Venusaur!.bin
/dlc/IRAO/CGEAR2/12 - Charizard!.bin
/dlc/IRAO/CGEAR2/13 - Blastoise!.bin
/dlc/IRAO/CGEAR2/14 - Red Genesect.bin
/dlc/IRAO/CGEAR2/15 - Black Kyurem.bin
/dlc/IRAO/CGEAR2/16 - White Kyurem.bin
/dlc/IRAO/MUSICAL/01 - Charming Munna.bin
/dlc/IRAO/MUSICAL/02 - MELOETTAAA!!!.bin
/dlc/IRAO/ZUKAN/01 - Default (Pink).bin
/dlc/IRAO/ZUKAN/02 - Default (Red).bin
/dlc/IRAO/ZUKAN/03 - Unova Trio (Pink).bin
/dlc/IRAO/ZUKAN/04 - Unova Trio (Red).bin
/dlc/IRAO/ZUKAN/05 - Kanto Trio (Pink).bin
/dlc/IRAO/ZUKAN/06 - Kanto Trio (Red).bin
/dlc/IRAO/ZUKAN/07 - Hugh.bin
/dlc/IRAO/ZUKAN/08 - Bianca.bin
/dlc/IRAO/ZUKAN/09 - Cheren.bin
/dlc/IRAO/CGEAR/01 - Default.bin 19c71995ffb00f4bdc59bfa805eee8fa
/dlc/IRAO/CGEAR/02 - Meadow Munna.bin d434decfee8faad9485560fbc9206273
/dlc/IRAO/CGEAR/03 - Aim for the Top.bin c93f457ae1310bfe03f7ede622c7f63a
/dlc/IRAO/CGEAR/04 - Twinkle Minccino.bin 86902dc9ec625e05673697367d492898
/dlc/IRAO/CGEAR/05 - PokeSma Purrloin.bin 0efe27a2cf7e48de2273ccba0fda2031
/dlc/IRAO/CGEAR/06 - Earful Audino.bin ee383864a67e6a04235c7f6a891a9539
/dlc/IRAO/CGEAR/07 - Happy Piplup.bin 88d0dfac0dda854486b8b5fb839e0bfd
/dlc/IRAO/CGEAR/08 - Poison Jab Croagunk.bin 7294c3e4db163beb98ea49be10d8eb2a
/dlc/IRAO/CGEAR/09 - Venusaur!.bin 7c51d55a63117bb166cc507f064f7cca
/dlc/IRAO/CGEAR/10 - Charizard!.bin a8ccb7866393efda3422656aa2d8f292
/dlc/IRAO/CGEAR/11 - Blastoise!.bin e0a1b44450407af394f390c0b229e7a6
/dlc/IRAO/CGEAR/12 - V for Victory!.bin 847d6d230fe2513758d50adac52d54b8
/dlc/IRAO/CGEAR/13 - Virtual Pokemon.bin 002263910ed018ff04922c4f3971b777
/dlc/IRAO/CGEAR/14 - Pokemon Cafe.bin efd6e6de86ea892dda840bcc6952afc1
/dlc/IRAO/CGEAR/15 - Hero Reshiram.bin d89ceeb9a9e7774fa802c1aa2f38409a
/dlc/IRAO/CGEAR/16 - Hero Zekrom.bin 2548f0ed48e130649ff351ca418e3b7f
/dlc/IRAO/CGEAR/17 - 2011 Worlds C-Gear.bin 70119e0a5e67fb4c9dda7b724e157784
/dlc/IRAO/CGEAR/18 - Zoroark!.bin 81e9241acb3270e2d490dd9966f3ecf1
/dlc/IRAO/CGEAR/19 - Spring Deerling.bin ca785e48d49269a182ee0fe0981168a8
/dlc/IRAO/CGEAR/20 - Summer Deerling.bin 0e8b6b527a411aeb302cfe07cb3679b2
/dlc/IRAO/CGEAR/21 - Autumn Deerling.bin 7155fca4dc915fc6cb87495fa7a9c743
/dlc/IRAO/CGEAR/22 - Winter Deerling.bin 7f9d3448f40d0ea513c1f94e311e77bb
/dlc/IRAO/CGEAR/23 - CRUSTLE!.bin eb1a0cb83e330375c257cb61bbc80e84
/dlc/IRAO/CGEAR/24 - KLINK!.bin f4ebe5cbb0de53c44f0c620215e840b4
/dlc/IRAO/CGEAR/25 - Ducklett Friends.bin bb359435efd5e98f48beba2cf71126d9
/dlc/IRAO/CGEAR/26 - Guidance Cobalion.bin fcd86f0f98b1d770e28001c02f455c58
/dlc/IRAO/CGEAR/27 - Trial Terrakion.bin 6c57b06edcaa0aeb6b716ecd5d3f2336
/dlc/IRAO/CGEAR/28 - Rumination Virizion.bin b02dce72dfd25a4c8d417b87f3a99329
/dlc/IRAO/CGEAR/29 - Keldeo Ordinary Forme.bin a83088c9cf11277049ed472d302b0dd6
/dlc/IRAO/CGEAR/30 - Aria of the Night Sky.bin 4306211f215bb0d397c92037812d37ae
/dlc/IRAO/CGEAR/31 - 2012 Worlds C-Gear.bin 225505e34dab2ccd300e3b4bd7eda47f
/dlc/IRAO/CGEAR/32 - Red Genesect.bin 614be39e965dab594a5ffaac6bd5ae82
/dlc/IRAO/CGEAR/33 - PCN Cup 2024 Summer.bin f51d9e9045b51dcc1d02cc8091c520e7
/dlc/IRAO/CGEAR2/01 - Default.bin d7ab435f5016ac3e6a7f444ccd25f424
/dlc/IRAO/CGEAR2/02 - Meadow Munna.bin 98b944531d9206512bbc5f59f8229139
/dlc/IRAO/CGEAR2/03 - Keldeo Resolute Forme.bin e7bddaf878faa5b009caee7d96998954
/dlc/IRAO/CGEAR2/04 - 2012 Worlds C-Gear.bin 36d5948c310428b251b70ec1dcf0845a
/dlc/IRAO/CGEAR2/05 - Pumpkin Pikachu.bin 7fd54cd2bd5310c40139b4329124aa2d
/dlc/IRAO/CGEAR2/06 - V for Victory!.bin 4f9a1ca435561cfe49d892488b398caa
/dlc/IRAO/CGEAR2/07 - Hero Reshiram.bin ec233268fb4d04cbb13b8b944552e94b
/dlc/IRAO/CGEAR2/08 - Hero Zekrom.bin 4647c982bbbd3955b4d8e4c9309ff7ef
/dlc/IRAO/CGEAR2/09 - Aria of the Night Sky.bin 2f609aeda35b34b6386cfa66d57e0ad5
/dlc/IRAO/CGEAR2/10 - Sleeping Eevee.bin 4882e949ae9c6de8cb2735942f0b1624
/dlc/IRAO/CGEAR2/11 - Venusaur!.bin 21becbbbba091cb1060ed19c4436d98f
/dlc/IRAO/CGEAR2/12 - Charizard!.bin 75705d719bae83ac130e03598d85382d
/dlc/IRAO/CGEAR2/13 - Blastoise!.bin 4f41e613d79826f48b8f8b9d1aa5747b
/dlc/IRAO/CGEAR2/14 - Red Genesect.bin 0d0951b899bff3d9e243a9dc61aff973
/dlc/IRAO/CGEAR2/15 - Black Kyurem.bin ff4356dac514c364557bd685c932f43e
/dlc/IRAO/CGEAR2/16 - White Kyurem.bin e51c69805f87a954a7fd563b920cc036
/dlc/IRAO/CGEAR2/17 - PCN Cup 2024 Summer.bin e925ddca5d9a52c49a3463916f6fcc0a
/dlc/IRAO/MUSICAL/01 - Charming Munna.bin dd180c539b51305244c10380266ec2ac
/dlc/IRAO/MUSICAL/02 - MELOETTAAA!!!.bin f1a18eaf27c60b43020e9d5c0195eaed
/dlc/IRAO/ZUKAN/01 - Default (Pink).bin 7150719a18ffacef993c58b112cfaf52
/dlc/IRAO/ZUKAN/02 - Default (Red).bin bc396d6403d9d296c1b4700d31b6eb42
/dlc/IRAO/ZUKAN/03 - Unova Trio (Pink).bin 2e80bbc0e824c9c17fb0b5480d6bcb7c
/dlc/IRAO/ZUKAN/04 - Unova Trio (Red).bin b3e3eadbad327a8c4b3889768f79fd8e
/dlc/IRAO/ZUKAN/05 - Kanto Trio (Pink).bin abbe82e2ff4c66d0557b9989da3f5c29
/dlc/IRAO/ZUKAN/06 - Kanto Trio (Red).bin 7ba1f19f564e271d2e92810e174cd75e
/dlc/IRAO/ZUKAN/07 - Hugh.bin 2f74e77b34edc1758226778e9f5e91fd
/dlc/IRAO/ZUKAN/08 - Bianca.bin f5c57f66af3e096c65850ee47882be90
/dlc/IRAO/ZUKAN/09 - Cheren.bin 2dad78cb515505a5de8022bbd8ecd2ca
/dlc/IRAO/MYSTERY/PCN Cup 2024 Summer.bin fed0ff82be6903777b2f107ce00e8e4c

Binary file not shown.

After

Width:  |  Height:  |  Size: 720 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -1,25 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE xml>
<Configuration status="off" packages="entralinked.utility">
<Configuration status="WARN" packages="entralinked.utility">
<Properties>
<Property name="LOGS_DIR">logs</Property>
<Property name="PATTERN_MINIMAL">%d{yyyy-MM-dd HH:mm:ss.SSS} %5level : %msg%n</Property>
<Property name="PATTERN_DETAILED">%d{yyyy-MM-dd HH:mm:ss.SSS} %5level [%32t] %-48c{1.2*} : %msg%n</Property>
</Properties>
<Appenders>
<Console name="Console" target="SYSTEM_OUT">
<PatternLayout pattern="%d{yyyy-MM-dd HH:mm:ss.SSS} %5level [%32t] %-48c{1.2*} : %msg%n"/>
<PatternLayout pattern="${PATTERN_DETAILED}"/>
<ThresholdFilter level="INFO"/>
</Console>
<RollingFile name="LogFile" fileName="logs/latest.log" filePattern="logs/%d{yyyy-MM-dd}-%i.log.gz">
<PatternLayout pattern="%d{yyyy-MM-dd HH:mm:ss.SSS} %5level [%16t] %-48c{1.2*} : %msg%n"/>
<RollingFile name="LogFile" fileName="${LOGS_DIR}/latest.log" filePattern="${LOGS_DIR}/%d{yyyy-MM-dd}-%i.log.gz">
<PatternLayout pattern="${PATTERN_DETAILED}"/>
<ThresholdFilter level="INFO"/>
<Policies>
<OnStartupTriggeringPolicy/>
</Policies>
</RollingFile>
<ConsumerAppender name="GuiOutput">
<PatternLayout pattern="%d{yyyy-MM-dd HH:mm:ss.SSS} %5level : %msg%n"/>
<PatternLayout pattern="${PATTERN_MINIMAL}"/>
<ThresholdFilter level="INFO"/>
</ConsumerAppender>
<File name="DebugLogFile" fileName="${LOGS_DIR}/debug.log" append="false">
<PatternLayout pattern="${PATTERN_DETAILED}"/>
<ThresholdFilter level="DEBUG"/>
</File>
</Appenders>
<Loggers>
<Root level="info">
<AppenderRef ref="Console"/>
<Logger name="entralinked" additivity="false">
<AppenderRef ref="LogFile"/>
<AppenderRef ref="GuiOutput"/>
</Root>
<AppenderRef ref="DebugLogFile"/>
<AppenderRef ref="Console"/>
</Logger>
<Root level="all"/>
</Loggers>
</Configuration>

View File

@@ -0,0 +1,93 @@
package entralinked.serialization;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.junit.jupiter.api.TestInstance.Lifecycle;
import com.fasterxml.jackson.core.JsonParseException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.ObjectMapper;
@TestInstance(Lifecycle.PER_CLASS)
public class GameSpyMessageFactoryTest {
protected ObjectMapper mapper;
@BeforeAll
void before() {
mapper = new ObjectMapper(new GameSpyMessageFactory());
}
@Test
@DisplayName("Test if generator writes objects correctly")
void testGeneratorWriteObject() throws IOException {
Map<String, Object> data = new LinkedHashMap<>();
data.put("key", "value");
data.put("emptyValue", "");
data.put("hello", "world");
data.put("numberTest", 123);
assertEquals("\\key\\value\\emptyValue\\\\hello\\world\\numberTest\\123", mapper.writeValueAsString(data));
}
@Test
@DisplayName("Test if generator throws exception when writing a nested object")
void testGeneratorThrowsExceptionWhenWritingNestedObject() {
Map<String, Object> data = Map.of("someKey", "someValue", "nestedObjectKey", Map.of("key", "value"));
JsonMappingException exception = assertThrows(JsonMappingException.class, () -> mapper.writeValueAsString(data));
assertEquals("this format does not support nested objects", exception.getOriginalMessage());
}
@Test
@DisplayName("Test if generator throws exception when writing an array")
void testGeneratorThrowsExceptionWhenWritingArray() {
Map<String, Object> data = Map.of("arrayKey", List.of(1, 2, 3, 4));
JsonMappingException exception = assertThrows(JsonMappingException.class, () -> mapper.writeValueAsString(data));
assertEquals("this format does not support arrays", exception.getOriginalMessage());
}
@Test
@DisplayName("Test if parser creates object from input string correctly")
void testParserReadObject() throws IOException {
String inputString = "\\key\\value\\emptyValue\\\\hello\\world\\numberTest\\123";
Map<String, Object> data = mapper.readValue(inputString, new TypeReference<Map<String, Object>>(){});
assertEquals("value", data.get("key"));
assertEquals("", data.get("emptyValue"));
assertEquals("world", data.get("hello"));
assertEquals("123", data.get("numberTest"));
}
@Test
@DisplayName("Test if parser throws exception when reading an empty key")
void testParserThrowsExceptionWhenReadingEmptyKey() {
String inputString = "\\some\\value\\\\emptyKey";
JsonParseException exception = assertThrows(JsonParseException.class, () -> mapper.readValue(inputString, Map.class));
assertEquals("Unexpected character ('\\' (code 92)): expected field name", exception.getOriginalMessage());
}
@Test
@DisplayName("Test if parser throws exception when input doesn't start with backslash")
void testParserThrowsExceptionWhenFirstCharacterNotBackslash() {
String inputString = "key\\value";
JsonParseException exception = assertThrows(JsonParseException.class, () -> mapper.readValue(inputString, Map.class));
assertEquals("Unexpected character ('k' (code 107)): expected '\\' to open field name", exception.getOriginalMessage());
}
@Test
@DisplayName("Test if parser throws exception when field name isn't closed")
void testParserThrowsExceptionWhenFieldNameNotClosed() {
String inputString = "\\hello\\world\\key";
JsonParseException exception = assertThrows(JsonParseException.class, () -> mapper.readValue(inputString, Map.class));
assertEquals("Unexpected end-of-input in FIELD_NAME", exception.getOriginalMessage());
}
}

View File

@@ -0,0 +1,94 @@
package entralinked.serialization;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.junit.jupiter.api.TestInstance.Lifecycle;
import com.fasterxml.jackson.core.JsonParseException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.ObjectMapper;
@TestInstance(Lifecycle.PER_CLASS)
public class UrlEncodedFormFactoryTest {
protected ObjectMapper mapper;
protected ObjectMapper mapperNoBase64;
@BeforeAll
void before() {
mapper = new ObjectMapper(new UrlEncodedFormFactory());
mapperNoBase64 = new ObjectMapper(new UrlEncodedFormFactory()
.disable(UrlEncodedFormParser.Feature.BASE64_DECODE_VALUES)
.disable(UrlEncodedFormGenerator.Feature.BASE64_ENCODE_VALUES));
}
@Test
@DisplayName("Test if generator writes objects correctly")
void testGeneratorWriteObject() throws IOException {
Map<String, Object> data = new LinkedHashMap<>();
data.put("hello", "world");
data.put("test", "space test");
data.put("emptyValue", "");
data.put("someNumber", 1234567890);
assertEquals("hello=d29ybGQ*&test=c3BhY2UgdGVzdA**&emptyValue=&someNumber=MTIzNDU2Nzg5MA**", mapper.writeValueAsString(data));
assertEquals("hello=world&test=space+test&emptyValue=&someNumber=1234567890", mapperNoBase64.writeValueAsString(data));
}
@Test
@DisplayName("Test if generator throws exception when writing a nested object")
void testGeneratorThrowsExceptionWhenWritingNestedObject() {
Map<String, Object> data = Map.of("someKey", "someValue", "nestedObjectKey", Map.of("key", "value"));
JsonMappingException exception = assertThrows(JsonMappingException.class, () -> mapper.writeValueAsString(data));
assertEquals("this format does not support nested objects", exception.getOriginalMessage());
}
@Test
@DisplayName("Test if generator throws exception when writing an array")
void testGeneratorThrowsExceptionWhenWritingArray() {
Map<String, Object> data = Map.of("arrayKey", List.of(1, 2, 3, 4));
JsonMappingException exception = assertThrows(JsonMappingException.class, () -> mapper.writeValueAsString(data));
assertEquals("this format does not support arrays", exception.getOriginalMessage());
}
@Test
@DisplayName("Test if parser creates object from input string correctly")
void testParserReadObject() throws IOException {
String inputString = "hello=world&test=space+test&emptyValue=&someNumber=1234567890";
Map<String, Object> data = mapperNoBase64.readValue(inputString, new TypeReference<Map<String, Object>>(){});
assertEquals("world", data.get("hello"));
assertEquals("space test", data.get("test"));
assertEquals("", data.get("emptyValue"));
assertEquals("1234567890", data.get("someNumber"));
// Test Base64
inputString = "hello=d29ybGQ*&test=c3BhY2UgdGVzdA**&emptyValue=&someNumber=MTIzNDU2Nzg5MA**";
assertEquals(data, mapper.readValue(inputString, Map.class));
}
@Test
@DisplayName("Test if parser throws exception when reading an empty key")
void testParserThrowsExceptionWhenReadingEmptyKey() {
String inputString = "someKey=someValue&=emptyKey";
JsonParseException exception = assertThrows(JsonParseException.class, () -> mapperNoBase64.readValue(inputString, Map.class));
assertEquals("Unexpected character ('=' (code 61)): expected field name", exception.getOriginalMessage());
}
@Test
@DisplayName("Test if parser throws exception when field name isn't closed")
void testParserThrowsExceptionWhenFieldNameNotClosed() {
String inputString = "someKey=someValue&notClosed";
JsonParseException exception = assertThrows(JsonParseException.class, () -> mapperNoBase64.readValue(inputString, Map.class));
assertEquals("Unexpected end-of-input in FIELD_NAME", exception.getOriginalMessage());
}
}

View File

@@ -0,0 +1,54 @@
package entralinked.utility;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
public class ColorUtilityTest {
@Test
@DisplayName("Test if multiplied colors are correct")
void testColorMultiplying() {
assertEquals(0x7F7F7F, ColorUtility.multiplyColor(0xFFFFFF, 0.5));
assertEquals(0xFEFEFE, ColorUtility.multiplyColor(0x7F7F7F, 2.0));
// Test clamping
assertEquals(0xFFFFFF, ColorUtility.multiplyColor(0xFEFEFE, 2.0));
assertEquals(0x000000, ColorUtility.multiplyColor(0x010101, 0.5));
}
@Test
@DisplayName("Test if colors converted from RGB888 to BGR555 are correct")
void testRGB88ToBGR555Converter() {
// Black, white, red, green and blue
assertEquals(0x0000, ColorUtility.convertRGB888ToBGR555(0x000000));
assertEquals(0x7FFF, ColorUtility.convertRGB888ToBGR555(0xFFFFFF));
assertEquals(0x001F, ColorUtility.convertRGB888ToBGR555(0xFF0000));
assertEquals(0x03E0, ColorUtility.convertRGB888ToBGR555(0x00FF00));
assertEquals(0x7C00, ColorUtility.convertRGB888ToBGR555(0x0000FF));
// Random colors
assertEquals(0x07C7, ColorUtility.convertRGB888ToBGR555(0x39F20C));
assertEquals(0x68B3, ColorUtility.convertRGB888ToBGR555(0x9E2BD3));
assertEquals(0x6DFE, ColorUtility.convertRGB888ToBGR555(0xF07CDE));
assertEquals(0x22A4, ColorUtility.convertRGB888ToBGR555(0x26AC44));
}
@Test
@DisplayName("Test if colors converted from BGR555 to RGB888 are correct")
void testBGR555ToRGB888Converter() {
// Black, white, red, green and blue
assertEquals(0x000000, ColorUtility.convertBGR555ToRGB888(0x0000));
assertEquals(0xFFFFFF, ColorUtility.convertBGR555ToRGB888(0xFFFF));
assertEquals(0xFF0000, ColorUtility.convertBGR555ToRGB888(0x001F));
assertEquals(0x00FF00, ColorUtility.convertBGR555ToRGB888(0x03E0));
assertEquals(0x0000FF, ColorUtility.convertBGR555ToRGB888(0x7C00));
// Random colors
assertEquals(0x7BA59C, ColorUtility.convertBGR555ToRGB888(0x4E8F));
assertEquals(0x948CFF, ColorUtility.convertBGR555ToRGB888(0xFE32));
assertEquals(0xBD2121, ColorUtility.convertBGR555ToRGB888(0x1097));
assertEquals(0x5A185A, ColorUtility.convertBGR555ToRGB888(0xAC6B));
}
}

View File

@@ -0,0 +1,34 @@
package entralinked.utility;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
public class LittleEndianStreamTest {
@Test
@DisplayName("Test if LEOutputStream and LEInputStream produce correct values")
void testLittleEndianStreams() throws IOException {
short shortValue = 0x6A84;
int intValue = 0xF827EC80;
long longValue = 0x948EC1AB3F2C88L;
// Test writing
ByteArrayOutputStream byteOutputStream = new ByteArrayOutputStream();
LEOutputStream outputStream = new LEOutputStream(byteOutputStream);
outputStream.writeShort(shortValue);
outputStream.writeInt(intValue);
outputStream.writeLong(longValue);
// Test reading
LEInputStream inputStream = new LEInputStream(new ByteArrayInputStream(byteOutputStream.toByteArray()));
assertEquals(shortValue, inputStream.readShort());
assertEquals(intValue, inputStream.readInt());
assertEquals(longValue, inputStream.readLong());
}
}