Commit source

This commit is contained in:
kuroppoi 2023-06-22 21:55:19 +02:00
parent 43c9e84d69
commit 7ce0211e25
149 changed files with 6658 additions and 1 deletions

6
.gitattributes vendored Normal file
View File

@ -0,0 +1,6 @@
#
# https://help.github.com/articles/dealing-with-line-endings/
#
# These are explicitly windows files and should use crlf
*.bat text eol=crlf

19
.github/workflows/dist-pull-request.yml vendored Normal file
View File

@ -0,0 +1,19 @@
name: Build on Pull Request
on: pull_request
jobs:
dist:
runs-on: ubuntu-lastest
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: Setup Gradle
uses: gradle/gradle-build-action@v2
- name: Run Gradle dist
run: ./gradlew dist

View File

@ -0,0 +1,25 @@
name: Build and Upload Artifact
on: workflow_dispatch
jobs:
dist:
runs-on: ubuntu-lastest
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: Setup Gradle
uses: gradle/gradle-build-action@v2
- name: Run Gradle dist
run: ./gradlew dist
- name: Upload artifact
uses: actions/upload-artifact@v3
with:
name: entralinked
path: build/libs/entralinked.jar
retention-days: 7

12
.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
# Gradle
.gradle
build
run
testRun
# Eclipse
.metadata
.settings
.project
.classpath
bin

View File

@ -1 +1,28 @@
# entralinked
# Entralinked
[![build](https://github.com/kuroppoi/entralinked/actions/workflows/dist-upload-artifact.yml/badge.svg)](https://github.com/kuroppoi/entralinked/actions)
Entralinked is a standalone Game Sync emulator developed for use with Pokémon Black & White and Pokémon Black 2 & White 2.\
Its purpose is to serve as a simple utility for downloading Pokémon, Items, C-Gear skins, Pokédex skins and Musicals\
without needing to edit your save file.
## Building
#### Prerequisites
- Java 17 Development Kit
```
git clone --recurse-submodules https://github.com/kuroppoi/entralinked.git
cd entralinked
./gradlew dist
```
## Usage
Execute `entralinked.jar`, or without the user interface:
```
java -jar entralinked.jar disablegui
```
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 use the local host of the system.\
After tucking in a Pokémon, navigate to `http://localhost/dashboard/profile.html` in a web browser to configure Game Sync settings.

86
build.gradle Normal file
View File

@ -0,0 +1,86 @@
plugins {
id 'java'
}
project.ext {
mainClass = 'entralinked.Entralinked'
agentClass = 'entralinked.LauncherAgent'
workingDirectory = 'run'
}
repositories {
mavenCentral()
}
configurations {
signedImplementation
implementation.extendsFrom signedImplementation
}
dependencies {
testImplementation 'org.junit.jupiter:junit-jupiter:5.7.1'
signedImplementation 'org.bouncycastle:bcpkix-jdk15on:1.70'
implementation 'org.apache.logging.log4j:log4j-core:2.20.0'
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.javalin:javalin:5.5.0'
implementation 'org.apache.logging.log4j:log4j-slf4j2-impl:2.20.0'
}
sourceSets {
main {
resources {
srcDir 'poke-sprites-v'
}
}
}
test {
workingDir = "testRun";
useJUnitPlatform()
doFirst {
mkdir workingDir
}
}
compileJava {
options.encoding = "UTF-8"
}
task dist(type: Jar) {
manifest {
attributes 'Main-Class': project.ext.mainClass,
'Launcher-Agent-Class': project.ext.agentClass,
'Multi-Release': 'true'
}
from {
(configurations.runtimeClasspath - configurations.signedImplementation).collect {
it.isDirectory() ? it : zipTree(it)
}
}
from {
configurations.signedImplementation
}
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
dependsOn configurations.runtimeClasspath, test
with jar
}
task run(type: JavaExec) {
mainClass = project.ext.mainClass
workingDir = project.ext.workingDirectory
classpath = sourceSets.main.runtimeClasspath
doFirst {
mkdir workingDir
}
}
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17

1
gradle.properties Normal file
View File

@ -0,0 +1 @@
org.gradle.logging.level=info

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

240
gradlew vendored Normal file
View File

@ -0,0 +1,240 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
APP_NAME="Gradle"
APP_BASE_NAME=${0##*/}
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

91
gradlew.bat vendored Normal file
View File

@ -0,0 +1,91 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@ -0,0 +1,3 @@
@echo off
call ../gradlew clean -p .. --stacktrace
pause

3
scripts/gradlew-dist.bat Normal file
View File

@ -0,0 +1,3 @@
@echo off
call ../gradlew dist -p .. --stacktrace
pause

3
scripts/gradlew-test.bat Normal file
View File

@ -0,0 +1,3 @@
@echo off
call ../gradlew test -p .. --stacktrace
pause

1
settings.gradle Normal file
View File

@ -0,0 +1 @@
rootProject.name = 'entralinked'

View File

@ -0,0 +1,15 @@
package entralinked;
import java.util.Collection;
import java.util.List;
public record CommandLineArguments(boolean disableGui) {
public CommandLineArguments(Collection<String> args) {
this(args.contains("disablegui"));
}
public CommandLineArguments(String... args) {
this(List.of(args));
}
}

View File

@ -0,0 +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) {
public static final Configuration DEFAULT = new Configuration("local", true, false, true);
}

View File

@ -0,0 +1,156 @@
package entralinked;
import java.io.File;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.net.InetAddress;
import java.net.UnknownHostException;
import javax.swing.SwingUtilities;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import entralinked.gui.MainView;
import entralinked.model.dlc.DlcList;
import entralinked.model.player.PlayerManager;
import entralinked.model.user.UserManager;
import entralinked.network.dns.DnsServer;
import entralinked.network.gamespy.GameSpyServer;
import entralinked.network.http.HttpServer;
import entralinked.network.http.dashboard.DashboardHandler;
import entralinked.network.http.dls.DlsHandler;
import entralinked.network.http.nas.NasHandler;
import entralinked.network.http.pgl.PglHandler;
public class Entralinked {
public static void main(String[] args) {
new Entralinked(args);
}
private static final Logger logger = LogManager.getLogger();
private final ObjectMapper mapper = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT);
private final Configuration configuration;
private final DlcList dlcList;
private final UserManager userManager;
private final PlayerManager playerManager;
private final DnsServer dnsServer;
private final GameSpyServer gameSpyServer;
private final HttpServer httpServer;
public Entralinked(String[] args) {
// Read command line arguments
CommandLineArguments arguments = new CommandLineArguments(args);
// Create GUI if enabled
if(!arguments.disableGui()) {
try {
SwingUtilities.invokeAndWait(() -> new MainView(this));
} catch (InvocationTargetException | InterruptedException e) {
logger.error("An error occured whilst creating main view", e);
}
}
// Load config
configuration = loadConfigFile();
logger.info("Using configuration {}", configuration);
// Get host address
InetAddress hostAddress = null;
String hostName = configuration.hostName();
if(hostName.equals("local") || hostName.equals("localhost")) {
hostAddress = getLocalHost();
} else {
try {
hostAddress = InetAddress.getByName(hostName);
} catch(UnknownHostException e) {
hostAddress = getLocalHost();
logger.error("Could not resolve host name - falling back to {} ", hostAddress, e);
}
}
// Load persistent data
dlcList = new DlcList();
userManager = new UserManager();
playerManager = new PlayerManager();
// Create DNS server
dnsServer = new DnsServer(hostAddress);
dnsServer.start();
// Create GameSpy server
gameSpyServer = new GameSpyServer(this);
gameSpyServer.start();
// Create HTTP server
httpServer = new HttpServer(this);
httpServer.addHandler(new NasHandler(this));
httpServer.addHandler(new PglHandler(this));
httpServer.addHandler(new DlsHandler(this));
httpServer.addHandler(new DashboardHandler(this));
httpServer.start();
}
public void stopServers() {
if(httpServer != null) {
httpServer.stop();
}
if(gameSpyServer != null) {
gameSpyServer.stop();
}
if(dnsServer != null) {
dnsServer.stop();
}
}
private Configuration loadConfigFile() {
logger.info("Loading configuration ...");
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;
} else {
return mapper.readValue(configFile, Configuration.class);
}
} catch(IOException e) {
logger.error("Could not load configuration - default configuration will be used", e);
return Configuration.DEFAULT;
}
}
private InetAddress getLocalHost() {
try {
return InetAddress.getLocalHost();
} catch(UnknownHostException e) {
logger.error("Could not resolve local host", e);
return null;
}
}
public Configuration getConfiguration() {
return configuration;
}
public DlcList getDlcList() {
return dlcList;
}
public UserManager getUserManager() {
return userManager;
}
public PlayerManager getPlayerManager() {
return playerManager;
}
}

View File

@ -0,0 +1,104 @@
package entralinked;
import java.util.HashMap;
import java.util.Map;
public enum GameVersion {
// ==================================
// Black Version & White Version
// ==================================
BLACK_JAPANESE(21, 1, "IRBJ", "ブラック"),
BLACK_ENGLISH(21, 2, "IRBO", "Black Version"),
BLACK_FRENCH(21, 3, "IRBF", "Version Noire"),
BLACK_ITALIAN(21, 4, "IRBI", "Versione Nera"),
BLACK_GERMAN(21, 5, "IRBD", "Schwarze Edition"),
BLACK_SPANISH(21, 7, "IRBS", "Edicion Negra"),
BLACK_KOREAN(21, 8, "IRBK", "블랙"),
WHITE_JAPANESE(20, 1, "IRAJ", "ホワイト"),
WHITE_ENGLISH(20, 2, "IRAO", "White Version"),
WHITE_FRENCH(20, 3, "IRAF", "Version Blanche"),
WHITE_ITALIAN(20, 4, "IRAI", "Versione Bianca"),
WHITE_GERMAN(20, 5, "IRAD", "Weisse Edition"),
WHITE_SPANISH(20, 7, "IRAS", "Edicion Blanca"),
WHITE_KOREAN(20, 8, "IRAK", "화이트"),
// ==================================
// Black Version 2 & White Version 2
// ==================================
BLACK_2_JAPANESE(23, 1, "IREJ", "ブラック2", true),
BLACK_2_ENGLISH(23, 2, "IREO", "Black Version 2", true),
BLACK_2_FRENCH(23, 3, "IREF", "Version Noire 2", true),
BLACK_2_ITALIAN(23, 4, "IREI", "Versione Nera 2", true),
BLACK_2_GERMAN(23, 5, "IRED", "Schwarze Edition 2", true),
BLACK_2_SPANISH(23, 7, "IRES", "Edicion Negra 2", true),
BLACK_2_KOREAN(23, 8, "IREK", "블랙2", true),
WHITE_2_JAPANESE(22, 1, "IRDJ", "ホワイト2", true),
WHITE_2_ENGLISH(22, 2, "IRDO", "White Version 2", true),
WHITE_2_FRENCH(22, 3, "IRDF", "Version Blanche 2", true),
WHITE_2_ITALIAN(22, 4, "IRDI", "Versione Bianca 2", true),
WHITE_2_GERMAN(22, 5, "IRDD", "Weisse Edition 2", true),
WHITE_2_SPANISH(22, 7, "IRDS", "Edicion Blanca 2", true),
WHITE_2_KOREAN(22, 8, "IRDK", "화이트2", true);
// Lookup maps
private static final Map<String, GameVersion> mapBySerial = new HashMap<>();
private static final Map<Integer, GameVersion> mapByCodes = new HashMap<>();
static {
for(GameVersion version : values()) {
mapBySerial.put(version.getSerial(), version);
mapByCodes.put(version.getRomCode() << version.getLanguageCode(), version);
}
}
private final int romCode;
private final int languageCode; // Values are not tested
private final String serial;
private final String displayName;
private final boolean isVersion2;
private GameVersion(int romCode, int languageCode, String serial, String displayName, boolean isVersion2) {
this.romCode = romCode;
this.languageCode = languageCode;
this.serial = serial;
this.displayName = displayName;
this.isVersion2 = isVersion2;
}
private GameVersion(int romCode, int languageCode, String serial, String displayName) {
this(romCode, languageCode, serial, displayName, false);
}
public static GameVersion lookup(String serial) {
return mapBySerial.get(serial);
}
public static GameVersion lookup(int romCode, int languageCode) {
return mapByCodes.get(romCode << languageCode);
}
public int getRomCode() {
return romCode;
}
public int getLanguageCode() {
return languageCode;
}
public String getSerial() {
return serial;
}
public String getDisplayName() {
return displayName;
}
public boolean isVersion2() {
return isVersion2;
}
}

View File

@ -0,0 +1,47 @@
package entralinked;
import java.io.File;
import java.lang.instrument.Instrumentation;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.jar.JarFile;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
/**
* Stupid solution to a stupid problem.
* If this just randomly breaks in the future because of some nonsense security reason I will completely lose it.
*/
public class LauncherAgent {
private static final Logger logger = LogManager.getLogger();
private static boolean bouncyCastlePresent = true;
public static void agentmain(String args, Instrumentation instrumentation) {
try {
String[] jarNames = {
"bcutil-jdk15on-1.70.jar",
"bcprov-jdk15on-1.70.jar",
"bcpkix-jdk15on-1.70.jar"
};
for(int i = 0; i < jarNames.length; i++) {
String jarName = jarNames[i];
Path jarPath = Files.createTempFile(jarNames[i], null);
File jarFile = jarPath.toFile();
jarFile.deleteOnExit(); // Doesn't actually do anything on terminal exit because Java.
Files.copy(LauncherAgent.class.getResourceAsStream("/%s".formatted(jarName)), jarPath, StandardCopyOption.REPLACE_EXISTING);
instrumentation.appendToSystemClassLoaderSearch(new JarFile(jarFile));
}
} catch(Exception e) {
logger.error("Could not add BouncyCastle to SystemClassLoader search", e);
bouncyCastlePresent = false;
}
}
public static boolean isBouncyCastlePresent() {
return bouncyCastlePresent;
}
}

View File

@ -0,0 +1,125 @@
package entralinked.gui;
import java.awt.BorderLayout;
import java.awt.Desktop;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.io.IOException;
import java.net.URISyntaxException;
import java.net.URL;
import java.util.concurrent.CompletableFuture;
import javax.swing.JButton;
import javax.swing.JFrame;
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;
import javax.swing.text.Document;
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 entralinked.Entralinked;
import entralinked.utility.ConsumerAppender;
/**
* Simple Swing user interface.
*/
public class MainView {
private static Logger logger = LogManager.getLogger();
private final StyleContext styleContext = StyleContext.getDefaultStyleContext();
private final AttributeSet fontAttribute = styleContext.addAttribute(SimpleAttributeSet.EMPTY, StyleConstants.FontFamily, "Consolas");
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);
}
// Create dashboard button
JButton dashboardButton = new JButton("Open User Dashboard");
dashboardButton.setFocusable(false);
dashboardButton.addActionListener(event -> {
openUrl("http://127.0.0.1/dashboard/profile.html");
});
// Create console output
JTextPane consoleOutputPane = new JTextPane() {
@Override
public boolean getScrollableTracksViewportWidth() {
return getPreferredSize().width <= getParent().getSize().width;
}
@Override
public Dimension getPreferredSize() {
return getUI().getPreferredSize(this);
};
};
consoleOutputPane.setFont(new Font("Consola", Font.PLAIN, 12));
consoleOutputPane.setEditable(false);
((DefaultCaret)consoleOutputPane.getCaret()).setUpdatePolicy(DefaultCaret.ALWAYS_UPDATE);
// Create console output appender
ConsumerAppender.addConsumer("GuiOutput", message -> {
Document document = consoleOutputPane.getDocument();
try {
consoleOutputPane.getDocument().insertString(document.getLength(), message, fontAttribute);
} catch(BadLocationException e) {}
});
// Create console output scroll pane
JScrollPane scrollPane = new JScrollPane();
scrollPane.setVerticalScrollBarPolicy(JScrollPane.VERTICAL_SCROLLBAR_ALWAYS);
scrollPane.setViewportView(consoleOutputPane);
// Create main panel
JPanel panel = new JPanel(new BorderLayout());
panel.add(scrollPane, BorderLayout.CENTER);
panel.add(dashboardButton, BorderLayout.PAGE_END);
// Create window
JFrame frame = new JFrame("Entralinked");
frame.addWindowListener(new WindowAdapter() {
@Override
public void windowClosing(WindowEvent event) {
// Run asynchronously so it doesn't just awkwardly freeze
// Still scuffed but better than nothing I guess
CompletableFuture.runAsync(() -> {
entralinked.stopServers();
System.exit(0);
});
}
});
frame.setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE);
frame.setMinimumSize(new Dimension(512, 288));
frame.add(panel);
frame.pack();
frame.setLocationRelativeTo(null);
frame.setVisible(true);
}
private void openUrl(String url) {
Desktop desktop = Desktop.getDesktop();
if(desktop != null && desktop.isSupported(Desktop.Action.BROWSE)) {
try {
desktop.browse(new URL(url).toURI());
} catch(IOException | URISyntaxException e) {
e.printStackTrace();
}
}
}
}

View File

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

View File

@ -0,0 +1,185 @@
package entralinked.model.dlc;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.file.Files;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import entralinked.utility.Crc16;
public class DlcList {
private static final Logger logger = LogManager.getLogger();
private final Map<String, Dlc> dlcMap = new ConcurrentHashMap<>();
private final File dataDirectory = new File("dlc");
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();
}
System.out.println(outputFile);
// Copy resource to destination
if(resource != null) {
Files.copy(resource, outputFile.toPath());
}
}
} catch (IOException e) {
logger.error("Could not extract default DLC files", e);
return;
}
}
// Just to be sure...
if(!dataDirectory.isDirectory()) {
return;
}
// Game Serial level
for(File file : dataDirectory.listFiles()) {
// Make sure that file is a directory, log warning and skip otherwise
if(!file.isDirectory()) {
logger.warn("Non-directory '{}' in DLC root folder", file.getName());
continue;
}
// DLC Type level
for(File subFile : file.listFiles()) {
// Check if file is directory
if(!subFile.isDirectory()) {
logger.warn("Non-directory '{}' in DLC subfolder '{}'", file.getName(), subFile.getName());
continue;
}
int index = 1;
// DLC Content level
for(File dlcFile : subFile.listFiles()) {
// Load DLC data
Dlc dlc = loadDlcFile(file.getName(), subFile.getName(), index, dlcFile);
// Index DLC object if loading succeeded
if(dlc != null) {
dlcMap.put(dlc.name(), dlc);
index++;
}
}
}
}
logger.info("Loaded {} DLC file(s)", dlcMap.size());
}
private Dlc loadDlcFile(String gameCode, String type, int index, File dlcFile) {
String name = dlcFile.getName();
if(dlcMap.containsKey(name)) {
logger.warn("Duplicate DLC name {}", name);
return null;
}
if(dlcFile.isDirectory()) {
logger.warn("Directory '{}' in {} DLC folder", name, gameCode);
return null;
}
// Check if there is a valid CRC-16 checksum appended at the end of the file.
// If not, it will be marked in the DLC record object and the server will automatically append the checksum
// when the DLC content is requested.
// Makes it easier to just throw stuff into the DLC folder.
int projectedSize = 0;
int checksum = 0;
boolean checksumEmbedded = true;
try {
byte[] bytes = Files.readAllBytes(dlcFile.toPath());
projectedSize = bytes.length;
checksum = Crc16.calc(bytes, 0, bytes.length - 2);
int checksumInFile = (bytes[bytes.length - 2] & 0xFF) | ((bytes[bytes.length - 1] & 0xFF) << 8);
if(checksum != checksumInFile) {
projectedSize += 2;
checksum = Crc16.calc(bytes, 0, bytes.length);
checksumEmbedded = false;
}
} catch(IOException e) {
logger.error("Could not read checksum data for {}", dlcFile.getAbsolutePath(), e);
return null;
}
return new Dlc(dlcFile.getAbsolutePath(), name, gameCode, type, index, projectedSize, checksum, checksumEmbedded);
}
public List<Dlc> getDlcList(Predicate<Dlc> filter) {
return getDlc().stream().filter(filter).collect(Collectors.toList());
}
public List<Dlc> getDlcList(String gameCode, String type, int index) {
return getDlcList(dlc ->
dlc.gameCode().equals(gameCode) &&
dlc.type().equals(type) &&
dlc.index() == index
);
}
public List<Dlc> getDlcList(String gameCode, String type) {
return getDlcList(dlc ->
dlc.gameCode().equals(gameCode) &&
dlc.type().equals(type)
);
}
public List<Dlc> getDlcList(String gameCode) {
return getDlcList(dlc -> dlc.gameCode().equals(gameCode));
}
public String getDlcListString(Collection<Dlc> dlcList) {
StringBuilder builder = new StringBuilder();
dlcList.forEach(dlc -> {
builder.append("%s\t\t%s\t%s\t\t%s\r\n".formatted(dlc.name(), dlc.type(), dlc.index(), dlc.projectedSize()));
});
return builder.toString();
}
public Dlc getDlc(String name) {
return dlcMap.get(name);
}
public int getDlcIndex(String name) {
return dlcExists(name) ? getDlc(name).index() : 0;
}
public boolean dlcExists(String name) {
return name != null && dlcMap.containsKey(name);
}
public Collection<Dlc> getDlc() {
return Collections.unmodifiableCollection(dlcMap.values());
}
}

View File

@ -0,0 +1,11 @@
package entralinked.model.pkmn;
import com.fasterxml.jackson.annotation.JsonEnumDefaultValue;
public enum PkmnGender {
@JsonEnumDefaultValue
MALE,
FEMALE,
GENDERLESS;
}

View File

@ -0,0 +1,28 @@
package entralinked.model.pkmn;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* Record containing information about a Pokémon
*/
public record PkmnInfo(
@JsonProperty(required = true) int personality,
@JsonProperty(required = true) int species,
@JsonProperty(required = true) int heldItem,
@JsonProperty(required = true) int trainerId,
@JsonProperty(required = true) int trainerSecretId,
@JsonProperty(required = true) int level,
@JsonProperty(required = true) int form,
@JsonProperty(required = true) PkmnNature nature,
@JsonProperty(required = true) PkmnGender gender,
@JsonProperty(required = true) String nickname,
@JsonProperty(required = true) String trainerName) {
@JsonIgnore
public boolean isShiny() {
int p1 = (personality >> 16) & 0xFFFF;
int p2 = personality & 0xFFFF;
return (trainerId ^ trainerSecretId ^ p1 ^ p2) < 8;
}
}

View File

@ -0,0 +1,123 @@
package entralinked.model.pkmn;
import java.io.IOException;
import java.io.InputStream;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufAllocator;
import io.netty.buffer.PooledByteBufAllocator;
/**
* Utility class for reading binary Pokémon data.
*/
public class PkmnInfoReader {
private static final Logger logger = LogManager.getLogger();
private static final ByteBufAllocator bufferAllocator = PooledByteBufAllocator.DEFAULT;
private static final byte[] blockShuffleTable = {
0, 1, 2, 3, 0, 1, 3, 2, 0, 2, 1, 3, 0, 3, 1, 2,
0, 2, 3, 1, 0, 3, 2, 1, 1, 0, 2, 3, 1, 0, 3, 2,
2, 0, 1, 3, 3, 0, 1, 2, 2, 0, 3, 1, 3, 0, 2, 1,
1, 2, 0, 3, 1, 3, 0, 2, 2, 1, 0, 3, 3, 1, 0, 2,
2, 3, 0, 1, 3, 2, 0, 1, 1, 2, 3, 0, 1, 3, 2, 0,
2, 1, 3, 0, 3, 1, 2, 0, 2, 3, 1, 0, 3, 2, 1, 0
};
public static PkmnInfo readPokeInfo(InputStream inputStream) throws IOException {
ByteBuf buffer = bufferAllocator.buffer(236); // Allocate buffer
buffer.writeBytes(inputStream, 236); // Read data from input stream into buffer
// Read header info
int personality = buffer.readIntLE();
buffer.skipBytes(2);
int checksum = buffer.readShortLE() & 0x0000FFFF;
// Decrypt data
decryptData(buffer, 8, 128, checksum);
decryptData(buffer, 136, 100, personality);
// Unshuffle blocks
ByteBuf shuffleBuffer = bufferAllocator.buffer(128); // Allocate shuffle buffer
int shift = ((personality & 0x3E000) >> 0xD) % 24;
for(int i = 0; i < 4; i++) {
int fromIndex = blockShuffleTable[i + shift * 4] * 32;
int toIndex = i * 32;
shuffleBuffer.setBytes(toIndex, buffer, 8 + fromIndex, 32);
}
buffer.setBytes(8, shuffleBuffer, 0, 128);
// Try release shuffle buffer
if(!shuffleBuffer.release()) {
logger.warn("Buffer was not deallocated!");
}
// Read Pokémon data
int species = buffer.getShortLE(8);
int item = buffer.getShortLE(10) & 0xFFFF;
int trainerId = buffer.getShortLE(12) & 0xFFFF;
int trainerSecretId = buffer.getShortLE(14) & 0xFFFF;
int level = buffer.getByte(140);
int ability = buffer.getByte(21) & 0xFF;
int form = (buffer.getByte(64) >> 3) & 0xFF;
boolean genderless = ((buffer.getByte(64) >> 2) & 1) == 1;
boolean female = ((buffer.getByte(64) >> 1) & 1) == 1;
PkmnGender gender = genderless ? PkmnGender.GENDERLESS : female ? PkmnGender.FEMALE : PkmnGender.MALE;
PkmnNature nature = PkmnNature.valueOf(buffer.getByte(65));
String nickname = getString(buffer, 72, 20);
String trainerName = getString(buffer, 104, 14);
// Try release buffer
if(!buffer.release()) {
logger.warn("Buffer was not deallocated!");
}
// Loosely verify data
if(species < 1 || species > 649) throw new IOException("Invalid species");
if(item < 0 || item > 638) throw new IOException("Invalid held item");
if(ability < 1 || ability > 164) throw new IOException("Invalid ability");
if(level < 1 || level > 100) throw new IOException("Level is out of range");
if(nature == null) throw new IOException("Invalid nature");
// Create record
PkmnInfo info = new PkmnInfo(personality, species, item, trainerId, trainerSecretId, level, form, nature, gender, nickname, trainerName);
return info;
}
private static void decryptData(ByteBuf buffer, int offset, int length, int seed) throws IOException {
if(length % 2 != 0) {
throw new IOException("Length must be multiple of 2");
}
int tempSeed = seed;
for(int i = 0; i < length / 2; i++) {
int index = offset + i * 2;
short word = buffer.getShortLE(index);
tempSeed = 0x41C64E6D * tempSeed + 0x6073;
buffer.setShortLE(index, (short)(word ^ (tempSeed >> 16)));
}
}
private static String getString(ByteBuf buffer, int offset, int length) {
char[] charBuffer = new char[length];
int read = 0;
for(int i = 0; i < charBuffer.length; i++) {
int c = buffer.getShortLE(offset + i * 2) & 0xFFFF;
if(c == 0 || c == 65535) {
break; // Doubt 65535 is a legitimate character..
}
charBuffer[i] = (char)c;
read++;
}
return new String(charBuffer, 0, read);
}
}

View File

@ -0,0 +1,37 @@
package entralinked.model.pkmn;
import com.fasterxml.jackson.annotation.JsonEnumDefaultValue;
public enum PkmnNature {
@JsonEnumDefaultValue
HARDY,
LONELY,
BRAVE,
ADAMANT,
NAUGHTY,
BOLD,
DOCILE,
RELAXED,
IMPISH,
LAX,
TIMID,
HASTY,
SERIOUS,
JOLLY,
NAIVE,
MODEST,
MILD,
QUIET,
BASHFUL,
RASH,
CALM,
GENTLE,
SASSY,
CAREFUL,
QUIRKY;
public static PkmnNature valueOf(int index) {
return index >= 0 && index < values().length ? values()[index] : null;
}
}

View File

@ -0,0 +1,51 @@
package entralinked.model.player;
import com.fasterxml.jackson.annotation.JsonEnumDefaultValue;
public enum DreamAnimation {
/**
* Look around, but stay in the same position.
*/
@JsonEnumDefaultValue
LOOK_AROUND,
/**
* Walk around, but never change direction without moving a step in that direction.
*/
WALK_AROUND,
/**
* Walk around and occasionally change direction without moving.
*/
WALK_LOOK_AROUND,
/**
* Only walk up and down.
*/
WALK_VERTICALLY,
/**
* Only walk left and right.
*/
WALK_HORIZONTALLY,
/**
* Only walk left and right, and occasionally change direction without moving.
*/
WALK_LOOK_HORIZONTALLY,
/**
* Continuously spin right.
*/
SPIN_RIGHT,
/**
* Continuously spin left.
*/
SPIN_LEFT;
public static DreamAnimation valueOf(int index) {
return index >= 0 && index < values().length ? values()[index] : null;
}
}

View File

@ -0,0 +1,9 @@
package entralinked.model.player;
import com.fasterxml.jackson.annotation.JsonProperty;
public record DreamEncounter(
@JsonProperty(required = true) int species,
@JsonProperty(required = true) int move,
@JsonProperty(required = true) int form,
@JsonProperty(required = true) DreamAnimation animation) {}

View File

@ -0,0 +1,7 @@
package entralinked.model.player;
import com.fasterxml.jackson.annotation.JsonProperty;
public record DreamItem(
@JsonProperty(required = true) int id,
@JsonProperty(required = true) int quantity) {}

View File

@ -0,0 +1,117 @@
package entralinked.model.player;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import entralinked.GameVersion;
import entralinked.model.pkmn.PkmnInfo;
public class Player {
private final String gameSyncId;
private final GameVersion gameVersion;
private final List<DreamEncounter> encounters = new ArrayList<>();
private final List<DreamItem> items = new ArrayList<>();
private PlayerStatus status;
private PkmnInfo dreamerInfo;
private int levelsGained;
private String cgearSkin;
private String dexSkin;
private String musical;
public Player(String gameSyncId, GameVersion gameVersion) {
this.gameSyncId = gameSyncId;
this.gameVersion = gameVersion;
}
public void resetDreamInfo() {
status = PlayerStatus.AWAKE;
dreamerInfo = null;
encounters.clear();
items.clear();
levelsGained = 0;
cgearSkin = null;
dexSkin = null;
musical = null;
}
public String getGameSyncId() {
return gameSyncId;
}
public GameVersion getGameVersion() {
return gameVersion;
}
public void setEncounters(Collection<DreamEncounter> encounters) {
if(encounters.size() <= 10) {
this.encounters.clear();
this.encounters.addAll(encounters);
}
}
public List<DreamEncounter> getEncounters() {
return Collections.unmodifiableList(encounters);
}
public void setItems(Collection<DreamItem> items) {
if(encounters.size() <= 20) {
this.items.clear();
this.items.addAll(items);
}
}
public List<DreamItem> getItems() {
return Collections.unmodifiableList(items);
}
public void setStatus(PlayerStatus status) {
this.status = status;
}
public PlayerStatus getStatus() {
return status;
}
public void setDreamerInfo(PkmnInfo dreamerInfo) {
this.dreamerInfo = dreamerInfo;
}
public PkmnInfo getDreamerInfo() {
return dreamerInfo;
}
public void setLevelsGained(int levelsGained) {
this.levelsGained = levelsGained;
}
public int getLevelsGained() {
return levelsGained;
}
public void setCGearSkin(String cgearSkin) {
this.cgearSkin = cgearSkin;
}
public String getCGearSkin() {
return cgearSkin;
}
public void setDexSkin(String dexSkin) {
this.dexSkin = dexSkin;
}
public String getDexSkin() {
return dexSkin;
}
public void setMusical(String musical) {
this.musical = musical;
}
public String getMusical() {
return musical;
}
}

View File

@ -0,0 +1,47 @@
package entralinked.model.player;
import java.util.Collection;
import java.util.Collections;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import entralinked.GameVersion;
import entralinked.model.pkmn.PkmnInfo;
/**
* Serialization DTO for Global Link user information.
*/
public record PlayerDto(
@JsonProperty(required = true) String gameSyncId,
@JsonProperty(required = true) GameVersion gameVersion,
PlayerStatus status,
PkmnInfo dreamerInfo,
String cgearSkin,
String dexSkin,
String musical,
int levelsGained,
@JsonDeserialize(contentAs = DreamEncounter.class) Collection<DreamEncounter> encounters,
@JsonDeserialize(contentAs = DreamItem.class) Collection<DreamItem> items) {
public PlayerDto(Player player) {
this(player.getGameSyncId(), player.getGameVersion(), player.getStatus(), player.getDreamerInfo(), player.getCGearSkin(),
player.getDexSkin(), player.getMusical(), player.getLevelsGained(), player.getEncounters(), player.getItems());
}
/**
* Constructs a new {@link Player} object using the data in this DTO.
*/
public Player toPlayer() {
Player player = new Player(gameSyncId, gameVersion);
player.setStatus(status);
player.setDreamerInfo(dreamerInfo);
player.setCGearSkin(cgearSkin);
player.setDexSkin(dexSkin);
player.setMusical(musical);
player.setLevelsGained(levelsGained);
player.setEncounters(encounters == null ? Collections.emptyList() : encounters);
player.setItems(items == null ? Collections.emptyList() : items);
return player;
}
}

View File

@ -0,0 +1,160 @@
package entralinked.model.player;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.Collection;
import java.util.Collections;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.apache.logging.log4j.LogManager;
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)
*/
public class PlayerManager {
private static final Logger logger = LogManager.getLogger();
private final ObjectMapper mapper = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT);
private final Map<String, Player> playerMap = new ConcurrentHashMap<>();
private final File dataDirectory = new File("players");
public PlayerManager() {
logger.info("Loading player data ...");
// Check if player directory exists
if(!dataDirectory.exists()) {
return;
}
// Load player data
for(File file : dataDirectory.listFiles()) {
if(!file.isDirectory()) {
loadPlayer(file);
}
}
logger.info("Loaded {} player(s)", playerMap.size());
}
/**
* Loads a {@link Player} from the specified input file.
* The loaded player instance is automatically mapped, unless it has an already-mapped Game Sync ID.
*/
private void loadPlayer(File inputFile) {
try {
// Deserialize player data
Player player = mapper.readValue(inputFile, PlayerDto.class).toPlayer();
String gameSyncId = player.getGameSyncId();
// Check for duplicate Game Sync ID
if(doesPlayerExist(gameSyncId)) {
throw new IOException("Duplicate Game Sync ID %s".formatted(gameSyncId));
}
playerMap.put(gameSyncId, player);
} catch(IOException e) {
logger.error("Could not load player data at {}", inputFile.getAbsolutePath(), e);
}
}
/**
* Saves all player data.
*/
public void savePlayers() {
playerMap.values().forEach(this::savePlayer);
}
/**
* Saves the data of the specified player to disk, and returns {@code true} if it succeeds.
* The output file is generated as follows:
*
* {@code new File(dataDirectory, "PGL-%s".formatted(gameSyncId))}
*/
public boolean savePlayer(Player player) {
return savePlayer(player, new File(dataDirectory, "PGL-%s.json".formatted(player.getGameSyncId())));
}
/**
* Saves the data of the specified player to the specified output file.
*
* @return {@code true} if the data was saved successfully, {@code false} otherwise.
*/
private boolean savePlayer(Player player, File outputFile) {
try {
// Create parent directories
File parentFile = outputFile.getParentFile();
if(parentFile != null) {
parentFile.mkdirs();
}
// Serialize the entire player object first to minimize risk of corrupted files
byte[] bytes = mapper.writeValueAsBytes(new PlayerDto(player));
// Write serialized data to output file
Files.write(outputFile.toPath(), bytes);
} catch(IOException e) {
logger.error("Could not save player data for {}", player.getGameSyncId(), e);
return false;
}
return true;
}
/**
* Attempts to register a new {@link Player} with the given data.
*
* @return The newly created {@link Player} object if the registration succeeded.
* 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, GameVersion gameVersion) {
// Check for duplicate Game Sync ID
if(playerMap.containsKey(gameSyncId)) {
logger.warn("Attempted to register duplicate player {}", gameSyncId);
return null;
}
// Construct player object
Player player = new Player(gameSyncId, gameVersion);
player.setStatus(PlayerStatus.AWAKE);
// Try to save player data
if(!savePlayer(player)) {
return null;
}
// Map player object & return it
playerMap.put(gameSyncId, player);
return player;
}
/**
* @return {@code true} if a player with the specified Game Sync ID exists, {@code false} otherwise.
*/
public boolean doesPlayerExist(String gameSyncId) {
return playerMap.containsKey(gameSyncId);
}
/**
* @return The {@link Player} object to which this Game Sync ID belongs, or {@code null} if no such player exists.
*/
public Player getPlayer(String gameSyncId) {
return playerMap.get(gameSyncId);
}
/**
* @return An immutable {@link Collection} containing all players.
*/
public Collection<Player> getPlayers() {
return Collections.unmodifiableCollection(playerMap.values());
}
}

View File

@ -0,0 +1,27 @@
package entralinked.model.player;
import com.fasterxml.jackson.annotation.JsonEnumDefaultValue;
public enum PlayerStatus {
/**
* No Pokémon is currently tucked in.
*/
@JsonEnumDefaultValue
AWAKE,
/**
* The tucked in Pokémon is asleep, but is not dreaming yet.
*/
SLEEPING,
/**
* The tucked in Pokémon is dreaming - waking it up is not allowed.
*/
DREAMING,
/**
* The dreamer is ready to be woken up.
*/
WAKE_READY,
}

View File

@ -0,0 +1,58 @@
package entralinked.model.user;
public class GameProfile {
private final int id;
private String firstName;
private String lastName;
private String aimName;
private String zipCode;
public GameProfile(int id) {
this.id = id;
}
public GameProfile(int id, String firstName, String lastName, String aimName, String zipCode) {
this(id);
this.firstName = firstName;
this.lastName = lastName;
this.aimName = aimName;
this.zipCode = zipCode;
}
public int getId() {
return id;
}
public void setFirstName(String firstName) {
this.firstName = firstName;
}
public String getFirstName() {
return firstName;
}
public void setLastName(String lastName) {
this.lastName = lastName;
}
public String getLastName() {
return lastName;
}
public void setAimName(String aimName) {
this.aimName = aimName;
}
public String getAimName() {
return aimName;
}
public void setZipCode(String zipCode) {
this.zipCode = zipCode;
}
public String getZipCode() {
return zipCode;
}
}

View File

@ -0,0 +1,19 @@
package entralinked.model.user;
import com.fasterxml.jackson.annotation.JsonProperty;
public record GameProfileDto(
@JsonProperty(required = true) int id,
String firstName,
String lastName,
String aimName,
String zipCode) {
public GameProfileDto(GameProfile profile) {
this(profile.getId(), profile.getFirstName(), profile.getLastName(), profile.getAimName(), profile.getZipCode());
}
public GameProfile toProfile() {
return new GameProfile(id, firstName, lastName, aimName, zipCode);
}
}

View File

@ -0,0 +1,3 @@
package entralinked.model.user;
public record ServiceCredentials(String authToken, String challenge) {}

View File

@ -0,0 +1,15 @@
package entralinked.model.user;
import java.time.LocalDateTime;
import java.time.temporal.TemporalUnit;
public record ServiceSession(User user, String service, String branchCode, String challengeHash, LocalDateTime expiry) {
public ServiceSession(User user, String service, String branchCode, String challengeHash, long expiry, TemporalUnit expiryUnit) {
this(user, service, branchCode, challengeHash, LocalDateTime.now().plus(expiry, expiryUnit));
}
public boolean hasExpired() {
return LocalDateTime.now().isAfter(expiry);
}
}

View File

@ -0,0 +1,50 @@
package entralinked.model.user;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
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<>();
public User(String id, String password) {
this.id = id;
this.password = password;
}
public String getFormattedId() {
return "%s000".formatted(id).replaceAll("(.{4})(?!$)", "$1-");
}
public String getId() {
return id;
}
public String getPassword() {
return password;
}
protected void addProfile(String branchCode, GameProfile profile) {
profiles.put(branchCode, profile);
}
protected void removeProfile(String branchCode) {
profiles.remove(branchCode);
}
public GameProfile getProfile(String branchCode) {
return profiles.get(branchCode);
}
public Collection<GameProfile> getProfiles() {
return Collections.unmodifiableCollection(profiles.values());
}
protected Map<String, GameProfile> getProfileMap() {
return Collections.unmodifiableMap(profiles);
}
}

View File

@ -0,0 +1,30 @@
package entralinked.model.user;
import java.util.Map;
import java.util.Map.Entry;
import java.util.stream.Collectors;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
public record UserDto(
@JsonProperty(required = true) String id,
@JsonProperty(required = true) String password,
@JsonDeserialize(contentAs = GameProfileDto.class) Map<String, GameProfileDto> profiles) {
public UserDto(User user) {
this(user.getId(), user.getPassword(),
user.getProfileMap().entrySet()
.stream()
.collect(Collectors.toMap(Entry::getKey, entry -> new GameProfileDto(entry.getValue()))));
}
public User toUser() {
User user = new User(id, password);
profiles.entrySet()
.stream()
.collect(Collectors.toMap(Entry::getKey, e -> e.getValue().toProfile())) // Map GameProfileDto to GameProfile
.forEach(user::addProfile); // Add each GameProfile to the User object
return user;
}
}

View File

@ -0,0 +1,284 @@
package entralinked.model.user;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.time.temporal.ChronoUnit;
import java.util.Collection;
import java.util.Collections;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import entralinked.utility.CredentialGenerator;
import entralinked.utility.MD5;
/**
* Manager class for managing {@link User} information (Wi-Fi Connection users)
*
* TODO session management is a bit scuffed
*/
public class UserManager {
public static final Pattern USER_ID_PATTERN = Pattern.compile("[0-9]{13}");
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");
public UserManager() {
logger.info("Loading user and profile data ...");
// Check if directory exists
if(!dataDirectory.exists()) {
return;
}
// Load user data
for(File file : dataDirectory.listFiles()) {
if(!file.isDirectory()) {
loadUser(file);
}
}
logger.info("Loaded {} user(s) with a total of {} profile(s)", users.size(), profiles.size());
}
/**
* @return {@code true} if this is a valid user ID.
* That is, it has a length of 13 and only contains digits.
*/
public static boolean isValidUserId(String id) {
return USER_ID_PATTERN.matcher(id).matches();
}
/**
* Loads a {@link User} from the specified input file.
* The user is indexed automatically, unless it has a duplicate ID or any duplicate profile ID.
*/
private void loadUser(File inputFile) {
try {
// Deserialize user data
User user = mapper.readValue(inputFile, UserDto.class).toUser();
String id = user.getId();
// Check for duplicate user ID
if(users.containsKey(id)) {
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);
}
}
/**
* Saves the data of all users.
*/
public void saveUsers() {
users.values().forEach(this::saveUser);
}
/**
* Saves the data of the specified user to disk, and returns {@code true} if it succeeds.
* The output file is generated as follows:
*
* {@code new File(dataDirectory, "WFC-%s".formatted(formattedId))}
* where {@code formattedId} is the user ID formatted to {@code ####-####-####-#000}
*/
public boolean saveUser(User user) {
return saveUser(user, new File(dataDirectory, "WFC-%s.json".formatted(user.getFormattedId())));
}
/**
* Saves the data of the specified user to the specified output file.
*
* @return {@code true} if the data was saved successfully, {@code false} otherwise.
*/
private boolean saveUser(User user, File outputFile) {
try {
// Create parent directories
File parentFile = outputFile.getParentFile();
if(parentFile != null) {
parentFile.mkdirs();
}
// Serialize the entire user object first to minimize risk of corrupted files
byte[] bytes = mapper.writeValueAsBytes(new UserDto(user));
// Finally, write the data.
Files.write(outputFile.toPath(), bytes);
} catch(IOException e) {
logger.error("Could not save user data for user {}", user.getId(), e);
return false;
}
return true;
}
/**
* Generates credentials for the client to use when logging into a separate service.
* The information to be used by the service to verify the client will be cached and may be retrieved using {@link #getServiceSession(token, service)}.
*
* @return A {@link ServiceCredentials} record containing the auth token and (optional) challenge to send to the client.
*/
public ServiceCredentials createServiceSession(User user, String service, String branchCode) {
if(service == null) {
throw new IllegalArgumentException("service cannot be null");
}
// Create token
String authToken = "NDS%s".formatted(CredentialGenerator.generateAuthToken(96));
if(serviceSessions.containsKey(authToken)) {
return createServiceSession(user, service, branchCode); // Top 5 things that never happen
}
// Create challenge
String challenge = CredentialGenerator.generateChallenge(8);
// Create session object
ServiceSession session = new ServiceSession(user, service, branchCode, MD5.digest(challenge), 30, ChronoUnit.MINUTES);
serviceSessions.put(authToken, session);
return new ServiceCredentials(authToken, challenge);
}
/**
* @return A {@link ServiceSession} matching the specified auth token and service,
* or {@code null} if there is none or if the existing session expired.
*/
public ServiceSession getServiceSession(String authToken, String service) {
ServiceSession session = serviceSessions.get(authToken);
// Check if session exists
if(session == null) {
return null;
}
// Check if session has expired
if(session.hasExpired()) {
serviceSessions.remove(authToken);
return null;
}
return session.service().equals(service) ? session : null;
}
/**
* 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.
*/
public boolean 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;
}
// Create user
User user = new User(userId, plainPassword); // TODO hash
// Save user data and return null if it fails
if(!saveUser(user)) {
return false;
}
users.put(userId, user);
return true;
}
/**
* Simple method that returns a {@link User} object whose credentials match the given ones.
* If no user exists with matching credentials, {@code null} is returned instead.
*/
public User authenticateUser(String userId, String password) {
User user = users.get(userId);
return user == null || !user.getPassword().equals(password) ? null : user;
}
/**
* Creates a new profile for the specified user.
*
* @return The newly created profile, or {@code null} if profile creation failed.
*/
public GameProfile createProfileForUser(User user, String branchCode) {
// Check for duplicate profile
if(user.getProfile(branchCode) != null) {
logger.warn("Attempted to create duplicate profile {} in user {}", branchCode, user.getId());
return null;
}
int profileId = nextProfileId();
GameProfile profile = new GameProfile(profileId);
user.addProfile(branchCode, profile);
// Try to save user data and return null if it fails
if(!saveUser(user)) {
user.removeProfile(branchCode);
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}.
*/
public boolean doesUserExist(String id) {
return users.containsKey(id);
}
/**
* @return The {@link User} object to which this ID belongs, or {@code null} if it doesn't exist.
*/
public User getUser(String id) {
return users.get(id);
}
/**
* @return An immutable {@link Collection} containing all users.
*/
public Collection<User> getUsers() {
return Collections.unmodifiableCollection(users.values());
}
}

View File

@ -0,0 +1,76 @@
package entralinked.network;
import java.util.concurrent.ThreadFactory;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import io.netty.channel.ChannelFuture;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.epoll.Epoll;
import io.netty.channel.epoll.EpollEventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.util.concurrent.DefaultThreadFactory;
import io.netty.util.concurrent.Future;
public abstract class NettyServerBase {
private static final Logger logger = LogManager.getLogger();
protected final ThreadFactory threadFactory;
protected final EventLoopGroup eventLoopGroup;
protected final String name;
protected final int port;
protected boolean started;
public NettyServerBase(String name, int port) {
this.threadFactory = new DefaultThreadFactory(name);
this.name = name;
this.port = port;
if(Epoll.isAvailable()) {
eventLoopGroup = new EpollEventLoopGroup(threadFactory);
} else {
eventLoopGroup = new NioEventLoopGroup(threadFactory);
}
}
protected abstract ChannelFuture bootstrap(int port);
public boolean start() {
if(started) {
logger.warn("start() was called while {} server was already running!", name);
return true;
}
logger.info("Staring {} server ...", name);
ChannelFuture future = bootstrap(port);
if(!future.isSuccess()) {
logger.error("Could not start {} server", name, future.cause());
return false;
}
logger.info("{} server listening @ port {}", name, port);
started = true;
return true;
}
public boolean stop() {
if(!started) {
logger.warn("stop() was called while {} server wasn't running!", name);
return true;
}
logger.info("Stopping {} server ...", name);
Future<?> future = eventLoopGroup.shutdownGracefully().awaitUninterruptibly();
if(!future.isSuccess()) {
logger.info("Could not stop {} server", name, future.cause());
return false;
}
logger.info("{} server stopped", name);
started = false;
return true;
}
}

View File

@ -0,0 +1,45 @@
package entralinked.network.dns;
import java.net.InetAddress;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.codec.dns.DatagramDnsQuery;
import io.netty.handler.codec.dns.DatagramDnsResponse;
import io.netty.handler.codec.dns.DefaultDnsQuestion;
import io.netty.handler.codec.dns.DefaultDnsRawRecord;
import io.netty.handler.codec.dns.DnsRecordType;
import io.netty.handler.codec.dns.DnsSection;
public class DnsQueryHandler extends SimpleChannelInboundHandler<DatagramDnsQuery> {
private static final Logger logger = LogManager.getLogger();
private final InetAddress hostAddress;
public DnsQueryHandler(InetAddress hostAddress) {
this.hostAddress = hostAddress;
}
@Override
protected void channelRead0(ChannelHandlerContext ctx, DatagramDnsQuery query) throws Exception {
DefaultDnsQuestion question = query.recordAt(DnsSection.QUESTION);
DnsRecordType type = question.type();
// We only need type A (32 bit IPv4) for the DS
if(type != DnsRecordType.A) {
logger.warn("Unsupported record type in DNS question: {}", type);
return;
}
ByteBuf addressBuffer = Unpooled.wrappedBuffer(hostAddress.getAddress());
DefaultDnsRawRecord answer = new DefaultDnsRawRecord(question.name(), DnsRecordType.A, 0, addressBuffer);
DatagramDnsResponse response = new DatagramDnsResponse(query.recipient(), query.sender(), query.id());
response.addRecord(DnsSection.ANSWER, answer);
ctx.writeAndFlush(response);
}
}

View File

@ -0,0 +1,41 @@
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.ChannelFuture;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.socket.nio.NioDatagramChannel;
import io.netty.handler.codec.dns.DatagramDnsQueryDecoder;
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);
}
@Override
public ChannelFuture bootstrap(int port) {
return new Bootstrap()
.group(eventLoopGroup)
.channel(NioDatagramChannel.class)
.handler(new ChannelInitializer<NioDatagramChannel>() {
@Override
protected void initChannel(NioDatagramChannel channel) throws Exception {
channel.pipeline().addLast(new DatagramDnsQueryDecoder());
channel.pipeline().addLast(new DatagramDnsResponseEncoder());
channel.pipeline().addLast(new DnsQueryHandler(hostAddress));
}
}).bind(port).awaitUninterruptibly();
}
}

View File

@ -0,0 +1,208 @@
package entralinked.network.gamespy;
import java.nio.channels.ClosedChannelException;
import java.security.SecureRandom;
import java.util.function.Consumer;
import java.util.function.Supplier;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import entralinked.Entralinked;
import entralinked.model.user.GameProfile;
import entralinked.model.user.ServiceSession;
import entralinked.model.user.User;
import entralinked.model.user.UserManager;
import entralinked.network.gamespy.message.GameSpyChallengeMessage;
import entralinked.network.gamespy.message.GameSpyErrorMessage;
import entralinked.network.gamespy.message.GameSpyLoginResponse;
import entralinked.network.gamespy.message.GameSpyProfileResponse;
import entralinked.network.gamespy.request.GameSpyLoginRequest;
import entralinked.network.gamespy.request.GameSpyProfileRequest;
import entralinked.network.gamespy.request.GameSpyProfileUpdateRequest;
import entralinked.network.gamespy.request.GameSpyRequest;
import entralinked.utility.CredentialGenerator;
import entralinked.utility.MD5;
import io.netty.channel.Channel;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
/**
* GameSpy request handler.
*/
public class GameSpyHandler extends SimpleChannelInboundHandler<GameSpyRequest> {
private static final Logger logger = LogManager.getLogger();
private final SecureRandom secureRandom = new SecureRandom();
private final UserManager userManager;
private Channel channel;
private String serverChallenge;
private int sessionKey = -1; // It's pointless
private User user;
private GameProfile profile;
public GameSpyHandler(Entralinked entralinked) {
this.userManager = entralinked.getUserManager();
}
@Override
public void channelActive(ChannelHandlerContext ctx) {
channel = ctx.channel();
// Generate random server challenge
serverChallenge = CredentialGenerator.generateChallenge(10);
// Send challenge message
sendMessage(new GameSpyChallengeMessage(serverChallenge, 1));
}
@Override
public void channelInactive(ChannelHandlerContext ctx) {
// Clear data
serverChallenge = null;
sessionKey = -1;
user = null;
profile = null;
}
@Override
protected void channelRead0(ChannelHandlerContext ctx, GameSpyRequest request) throws Exception {
request.process(this);
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
if(cause instanceof ClosedChannelException) {
return; // Ignore this stupid exception.
}
logger.error("Exception caught in GameSpy handler", cause);
// Send error message and close channel afterwards.
sendErrorMessage(0x100, "An internal error occured on the server.", true, 0).addListener(future -> close());
}
public void handleLoginRequest(GameSpyLoginRequest request) {
String authToken = request.partnerToken();
String clientChallenge = request.challenge();
// Check if session exists
ServiceSession session = userManager.getServiceSession(authToken, "gamespy");
if(session == null) {
sendErrorMessage(0x200, "Invalid partner token.", request.sequenceId());
return;
}
// Verify client credential hash
String partnerChallengeHash = session.challengeHash();
String expectedResponse = createCredentialHash(partnerChallengeHash, authToken, clientChallenge, serverChallenge);
if(!expectedResponse.equals(request.response())) {
sendErrorMessage(0x202, "Invalid response.", request.sequenceId());
return;
}
// Fetch profile or create one if it doesn't exist
user = session.user();
profile = user.getProfile(session.branchCode());
if(profile == null) {
profile = userManager.createProfileForUser(user, session.branchCode());
// Check if creation succeeded
if(profile == null) {
sendErrorMessage(0x203, "Profile creation failed due to an error.", request.sequenceId());
return;
}
}
// Prepare and send response
sessionKey = secureRandom.nextInt(Integer.MAX_VALUE);
String proof = createCredentialHash(partnerChallengeHash, authToken, serverChallenge, clientChallenge);
sendMessage(new GameSpyLoginResponse(user.getId(), profile.getId(), proof, sessionKey, request.sequenceId()));
}
public void handleProfileRequest(GameSpyProfileRequest request) {
sendMessage(new GameSpyProfileResponse(profile, request.sequenceId()));
}
public void handleUpdateProfileRequest(GameSpyProfileUpdateRequest request) {
// Update profile info
boolean profileChanged = setValue(request::firstName, profile::setFirstName, profile::getFirstName);
profileChanged |= setValue(request::lastName, profile::setLastName, profile::getLastName);
profileChanged |= setValue(request::aimName, profile::setAimName, profile::getAimName);
profileChanged |= setValue(request::zipCode, profile::setZipCode, profile::getZipCode);
// Save user data if the profile was changed
if(profileChanged) {
userManager.saveUser(user);
}
}
/**
* Sets a value if it isn't {@code null} and returns {@code true} if the value is different from the current value.
*
* @param valueSupplier Supplies the value that needs to be set
* @param valueConsumer Consumes the value from {@code valueSupplier} (a setter)
* @param currentValueSupplier Supplies the current value to test against the new value
*/
private <T> boolean setValue(Supplier<T> valueSupplier, Consumer<T> valueConsumer, Supplier<T> currentValueSupplier) {
T value = valueSupplier.get();
// Return false if value is null or is equal to the existing value
if(value == null || value.equals(currentValueSupplier.get())) {
return false;
}
valueConsumer.accept(value);
return true;
}
protected String createCredentialHash(String passwordHash, String user, String inChallenge, String outChallenge) {
return MD5.digest("%s%s%s%s%s%s".formatted(
passwordHash,
" ",
user,
inChallenge,
outChallenge,
passwordHash));
}
public void destroySessionKey(int sessionKey) {
if(validateSessionKey(sessionKey)) {
sessionKey = -1;
}
}
public boolean validateSessionKey(int sessionKey) {
return validateSessionKey(sessionKey, 0);
}
public boolean validateSessionKey(int sessionKey, int sequenceId) {
if(sessionKey < 0 || this.sessionKey != sessionKey) {
sendErrorMessage(0x201, "Invalid session key.", sequenceId);
return false;
}
return true;
}
public ChannelFuture sendMessage(Object message) {
return channel.writeAndFlush(message).addListener(ChannelFutureListener.FIRE_EXCEPTION_ON_FAILURE);
}
public ChannelFuture sendErrorMessage(int errorCode, String errorMessage, boolean fatal, int sequenceId) {
return sendMessage(new GameSpyErrorMessage(errorCode, errorMessage, fatal ? 1 : 0, sequenceId));
}
public ChannelFuture sendErrorMessage(int errorCode, String errorMessage, int sequenceId) {
return sendErrorMessage(errorCode, errorMessage, false, sequenceId);
}
public ChannelFuture close() {
return channel.close();
}
}

View File

@ -0,0 +1,45 @@
package entralinked.network.gamespy;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import com.fasterxml.jackson.databind.ObjectMapper;
import entralinked.network.gamespy.message.GameSpyMessage;
import entralinked.serialization.GameSpyMessageFactory;
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.MessageToByteEncoder;
public class GameSpyMessageEncoder extends MessageToByteEncoder<Object> {
protected final ObjectMapper mapper;
/**
* Supplied {@link ObjectMapper} should be configured to use the {@link GameSpyMessageFactory}
*/
public GameSpyMessageEncoder(ObjectMapper mapper) {
this.mapper = mapper;
}
@Override
protected void encode(ChannelHandlerContext ctx, Object in, ByteBuf out) throws Exception {
Class<?> type = in.getClass();
GameSpyMessage messageInfo = type.getAnnotation(GameSpyMessage.class);
if(messageInfo == null) {
throw new IOException("Outbound message type '%s' must have the GameSpyMessage annotation.".formatted(type.getName()));
}
out.writeByte('\\');
writeString(out, messageInfo.name());
out.writeByte('\\');
writeString(out, messageInfo.value());
out.writeBytes(mapper.writeValueAsBytes(in));
writeString(out, "\\final\\");
}
private void writeString(ByteBuf out, String string) {
out.writeCharSequence(string, StandardCharsets.UTF_8);
}
}

View File

@ -0,0 +1,80 @@
package entralinked.network.gamespy;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.databind.ObjectMapper;
import entralinked.network.gamespy.request.GameSpyRequest;
import entralinked.serialization.GameSpyMessageFactory;
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.MessageToMessageDecoder;
public class GameSpyRequestDecoder extends MessageToMessageDecoder<ByteBuf> {
protected final ObjectMapper mapper;
protected final Map<String, Class<GameSpyRequest>> requestTypes;
/**
* Supplied {@link ObjectMapper} should be configured to use the {@link GameSpyMessageFactory}
*/
public GameSpyRequestDecoder(ObjectMapper mapper, Map<String, Class<GameSpyRequest>> requestTypes) {
this.mapper = mapper;
this.requestTypes = requestTypes;
}
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
// Sanity check
byte b = in.readByte();
if(b != '\\') {
throw new IOException("Was expecting '\\', got '%s'.".formatted((char)b));
}
// Get request type
String typeName = parseString(in, false);
Class<GameSpyRequest> requestType = requestTypes.get(typeName);
if(requestType == null) {
throw new IOException("Invalid or unimplemented request type '%s'".formatted(typeName));
}
// Parse request value (?) if any bytes are remaining
if(in.readableBytes() > 0) {
parseString(in, true);
}
// If there are still bytes left, use ObjectMapper to parse and map them.
// Otherwise, create empty instance using reflection.
GameSpyRequest request = null;
if(in.readableBytes() > 0) {
byte[] bytes = new byte[in.readableBytes() + 1];
bytes[0] = '\\'; // Cuz it was read as a terminator earlier..
in.readBytes(bytes, 1, bytes.length - 1);
request = mapper.readValue(bytes, requestType);
} else {
request = requestType.getConstructor().newInstance();
}
out.add(request);
}
private String parseString(ByteBuf in, boolean allowEOI) {
StringBuilder builder = new StringBuilder();
byte b = 0;
while((b = in.readByte()) != '\\') {
builder.append((char)b);
if(allowEOI && in.readableBytes() == 0) {
break;
}
}
return builder.toString();
}
}

View File

@ -0,0 +1,73 @@
package entralinked.network.gamespy;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import com.fasterxml.jackson.databind.DeserializationConfig;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.introspect.AnnotatedClass;
import com.fasterxml.jackson.databind.introspect.AnnotatedClassResolver;
import com.fasterxml.jackson.databind.jsontype.NamedType;
import entralinked.Entralinked;
import entralinked.network.NettyServerBase;
import entralinked.network.gamespy.request.GameSpyRequest;
import entralinked.serialization.GameSpyMessageFactory;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.buffer.Unpooled;
import io.netty.channel.Channel;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.DelimiterBasedFrameDecoder;
import io.netty.util.concurrent.DefaultEventExecutor;
import io.netty.util.concurrent.EventExecutorGroup;
public class GameSpyServer extends NettyServerBase {
private static final Logger logger = LogManager.getLogger();
private static final ObjectMapper mapper = new ObjectMapper(new GameSpyMessageFactory());
private static final Map<String, Class<GameSpyRequest>> requestTypes = new HashMap<>();
private final EventExecutorGroup handlerGroup = new DefaultEventExecutor(threadFactory);
private final Entralinked entralinked;
static {
logger.info("Mapping GameSpy request types ...");
DeserializationConfig config = mapper.getDeserializationConfig();
AnnotatedClass annotated = AnnotatedClassResolver.resolveWithoutSuperTypes(config, GameSpyRequest.class);
Collection<NamedType> types = mapper.getSubtypeResolver().collectAndResolveSubtypesByClass(config, annotated);
for(NamedType type : types) {
if(type.hasName()) {
requestTypes.put(type.getName(), (Class<GameSpyRequest>)type.getType());
}
}
}
public GameSpyServer(Entralinked entralinked) {
super("GameSpy", 29900);
this.entralinked = entralinked;
}
@Override
public ChannelFuture bootstrap(int port) {
return new ServerBootstrap()
.group(eventLoopGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<Channel>() {
@Override
protected void initChannel(Channel channel) throws Exception {
ChannelPipeline pipeline = channel.pipeline();
pipeline.addLast(new DelimiterBasedFrameDecoder(512, Unpooled.wrappedBuffer("\\final\\".getBytes())));
pipeline.addLast(new GameSpyRequestDecoder(mapper, requestTypes));
pipeline.addLast(new GameSpyMessageEncoder(mapper));
pipeline.addLast(handlerGroup, new GameSpyHandler(entralinked));
}
}).bind(port).awaitUninterruptibly();
}
}

View File

@ -0,0 +1,8 @@
package entralinked.network.gamespy.message;
import com.fasterxml.jackson.annotation.JsonProperty;
@GameSpyMessage(name = "lc", value = "1")
public record GameSpyChallengeMessage(
@JsonProperty("challenge") String challenge,
@JsonProperty("id") int sequenceId) {}

View File

@ -0,0 +1,10 @@
package entralinked.network.gamespy.message;
import com.fasterxml.jackson.annotation.JsonProperty;
@GameSpyMessage(name = "error")
public record GameSpyErrorMessage(
@JsonProperty("err") int errorCode,
@JsonProperty("errmsg") String errorMessage,
@JsonProperty("fatal") int fatal,
@JsonProperty("id") int sequenceId) {}

View File

@ -0,0 +1,11 @@
package entralinked.network.gamespy.message;
import com.fasterxml.jackson.annotation.JsonProperty;
@GameSpyMessage(name = "lc", value = "2")
public record GameSpyLoginResponse(
@JsonProperty("userid") String userId,
@JsonProperty("profileid") int profileId,
@JsonProperty("proof") String proof,
@JsonProperty("sesskey") int sessionKey,
@JsonProperty("id") int sequenceId) {}

View File

@ -0,0 +1,14 @@
package entralinked.network.gamespy.message;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface GameSpyMessage {
public String name();
public String value() default ""; // Still not sure what purpose this serves..
}

View File

@ -0,0 +1,24 @@
package entralinked.network.gamespy.message;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import entralinked.model.user.GameProfile;
import com.fasterxml.jackson.annotation.JsonProperty;
@JsonInclude(Include.NON_NULL)
@GameSpyMessage(name = "pi")
public record GameSpyProfileResponse(
@JsonProperty("profileid") int profileId,
@JsonProperty("firstname") String firstName,
@JsonProperty("lastname") String lastName,
@JsonProperty("aim") String aimName,
@JsonProperty("zipcode") String zipCode,
@JsonProperty("sig") String signature,
@JsonProperty("id") int sequenceId) {
public GameSpyProfileResponse(GameProfile profile, int sequenceId) {
this(profile.getId(), profile.getFirstName(), profile.getLastName(), profile.getAimName(), profile.getZipCode(), "signature", sequenceId);
}
}

View File

@ -0,0 +1,13 @@
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
}
}

View File

@ -0,0 +1,31 @@
package entralinked.network.gamespy.request;
import com.fasterxml.jackson.annotation.JsonProperty;
import entralinked.network.gamespy.GameSpyHandler;
public record GameSpyLoginRequest(
// Important stuff
@JsonProperty(value = "response", required = true) String response,
@JsonProperty(value = "challenge", required = true) String challenge,
@JsonProperty(value = "authtoken", required = true) String partnerToken,
@JsonProperty(value = "id", required = true) int sequenceId,
// Not-so-important stuff
@JsonProperty("userid") String userId,
@JsonProperty("gamename") String gameName,
@JsonProperty("profileid") int profileId,
@JsonProperty("namespaceid") int namespaceId,
@JsonProperty("partnerid") int partnerId,
@JsonProperty("productid") int productId,
@JsonProperty("sdkrevision") int sdkRevision,
@JsonProperty("firewall") int firewall,
@JsonProperty("port") int port,
@JsonProperty("quiet") int quiet
) implements GameSpyRequest {
@Override
public void process(GameSpyHandler handler) {
handler.handleLoginRequest(this);
}
}

View File

@ -0,0 +1,15 @@
package entralinked.network.gamespy.request;
import com.fasterxml.jackson.annotation.JsonProperty;
import entralinked.network.gamespy.GameSpyHandler;
public record GameSpyLogoutRequest(
@JsonProperty("sesskey") int sessionKey
) implements GameSpyRequest {
@Override
public void process(GameSpyHandler handler) {
handler.destroySessionKey(sessionKey);
}
}

View File

@ -0,0 +1,19 @@
package entralinked.network.gamespy.request;
import com.fasterxml.jackson.annotation.JsonProperty;
import entralinked.network.gamespy.GameSpyHandler;
public record GameSpyProfileRequest(
@JsonProperty(value = "sesskey", required = true) int sessionKey,
@JsonProperty(value = "id", required = true) int sequenceId,
@JsonProperty("profileid") int profileId
) implements GameSpyRequest {
@Override
public void process(GameSpyHandler handler) {
if(handler.validateSessionKey(sessionKey, sequenceId)) {
handler.handleProfileRequest(this);
}
}
}

View File

@ -0,0 +1,24 @@
package entralinked.network.gamespy.request;
import com.fasterxml.jackson.annotation.JsonProperty;
import entralinked.network.gamespy.GameSpyHandler;
public record GameSpyProfileUpdateRequest(
@JsonProperty(value = "sesskey", required = true) int sessionKey,
@JsonProperty("partnerid") int partnerId,
// Lots of possible values here, but these seem to be the only ones sent by generation 5 games.
@JsonProperty("firstname") String firstName,
@JsonProperty("lastname") String lastName,
@JsonProperty("aim") String aimName,
@JsonProperty("zipcode") String zipCode
) implements GameSpyRequest {
@Override
public void process(GameSpyHandler handler) {
if(handler.validateSessionKey(sessionKey)) {
handler.handleUpdateProfileRequest(this);
}
}
}

View File

@ -0,0 +1,19 @@
package entralinked.network.gamespy.request;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonSubTypes.Type;
import entralinked.network.gamespy.GameSpyHandler;
@JsonSubTypes({
@Type(name = "ka", value = GameSpyKeepAliveRequest.class),
@Type(name = "login", value = GameSpyLoginRequest.class),
@Type(name = "logout", value = GameSpyLogoutRequest.class),
@Type(name = "status", value = GameSpyStatusRequest.class),
@Type(name = "getprofile", value = GameSpyProfileRequest.class),
@Type(name = "updatepro", value = GameSpyProfileUpdateRequest.class),
})
public interface GameSpyRequest {
public void process(GameSpyHandler handler);
}

View File

@ -0,0 +1,17 @@
package entralinked.network.gamespy.request;
import com.fasterxml.jackson.annotation.JsonProperty;
import entralinked.network.gamespy.GameSpyHandler;
public record GameSpyStatusRequest(
@JsonProperty(value = "sesskey", required = true) int sessionKey,
@JsonProperty("statstring") String statusString,
@JsonProperty("locstring") String locationString
) implements GameSpyRequest {
@Override
public void process(GameSpyHandler handler) {
handler.validateSessionKey(sessionKey);
}
}

View File

@ -0,0 +1,16 @@
package entralinked.network.http;
import io.javalin.Javalin;
import io.javalin.config.JavalinConfig;
import io.javalin.http.Context;
import io.javalin.http.servlet.JavalinServletContext;
public interface HttpHandler {
public void addHandlers(Javalin javalin);
public default void configureJavalin(JavalinConfig config) {}
public default void clearTasks(Context ctx) {
((JavalinServletContext)ctx).getTasks().clear();
}
}

View File

@ -0,0 +1,11 @@
package entralinked.network.http;
import java.io.IOException;
import io.javalin.http.Context;
@FunctionalInterface
public interface HttpRequestHandler<T> {
public void process(T request, Context ctx) throws IOException;
}

View File

@ -0,0 +1,170 @@
package entralinked.network.http;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.security.GeneralSecurityException;
import java.security.KeyStore;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.eclipse.jetty.http.HttpVersion;
import org.eclipse.jetty.server.HttpConfiguration;
import org.eclipse.jetty.server.HttpConnectionFactory;
import org.eclipse.jetty.server.SecureRequestCustomizer;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.ServerConnector;
import org.eclipse.jetty.server.SslConnectionFactory;
import org.eclipse.jetty.util.ssl.SslContextFactory;
import entralinked.Entralinked;
import entralinked.LauncherAgent;
import entralinked.utility.CertificateGenerator;
import io.javalin.Javalin;
import io.javalin.http.HttpStatus;
import io.javalin.util.JavalinException;
public class HttpServer {
private static final Logger logger = LogManager.getLogger();
private static final String keyStorePassword = "password"; // Very secure!
private final Javalin javalin;
private boolean started;
public HttpServer(Entralinked entralinked) {
// Create certificate keystore
KeyStore keyStore = null;
if(LauncherAgent.isBouncyCastlePresent()) {
CertificateGenerator.initialize();
logger.info("Creating certificate keystore ...");
keyStore = createKeyStore();
if(keyStore == null) {
logger.warn("SSL will be disabled because keystore creation failed. You may have to manually sign a certificate.");
}
}
KeyStore _keyStore = keyStore; // Java moment
// Create Javalin instance
javalin = Javalin.create(config -> {
config.jetty.server(() -> createJettyServer(80, 443, _keyStore));
});
// Create exception handler
javalin.exception(Exception.class, (exception, ctx) -> {
logger.error("Caught exception", exception);
ctx.status(HttpStatus.INTERNAL_SERVER_ERROR);
});
// Conntest
javalin.get("/", ctx -> {
ctx.header("X-Organization", "Nintendo"); // Conntest fails with 052210-1 if this is not present
ctx.result("Test");
});
}
public void addHandler(HttpHandler handler) {
javalin.updateConfig(handler::configureJavalin); // Dirty
handler.addHandlers(javalin);
}
public boolean start() {
if(started) {
logger.warn("start() was called while HTTP server was already running!");
return true;
}
logger.info("Starting HTTP server ...");
try {
javalin.start();
} catch(JavalinException e) {
logger.error("Could not start HTTP server", e);
return false;
}
started = true;
return true;
}
public boolean stop() {
if(!started) {
logger.warn("stop() was called while HTTP server wasn't running!");
return true;
}
logger.info("Stopping HTTP server ...");
try {
javalin.stop();
} catch(JavalinException e) {
logger.error("Could not stop HTTP server", e);
return false;
}
started = false;
return true;
}
private KeyStore createKeyStore() {
try {
KeyStore keyStore = KeyStore.getInstance("PKCS12");
File keyStoreFile = new File("server.p12");
if(keyStoreFile.exists()) {
// Load keystore from file if it exists
logger.info("Cached keystore found - loading it!");
keyStore.load(new FileInputStream(keyStoreFile), keyStorePassword.toCharArray());
} else {
// Otherwise, generate a new one and store it in a file
logger.info("No keystore found - generating one!");
keyStore = CertificateGenerator.generateCertificateKeyStore("PKCS12", null);
keyStore.store(new FileOutputStream(keyStoreFile), keyStorePassword.toCharArray());
}
return keyStore;
} catch (GeneralSecurityException | IOException e) {
logger.error("Could not create keystore", e);
}
return null;
}
private Server createJettyServer(int port, int sslPort, KeyStore keyStore) {
Server server = new Server();
// Regular HTTP connector
ServerConnector httpConnector = new ServerConnector(server);
httpConnector.setPort(port);
server.addConnector(httpConnector);
if(keyStore != null) {
// Create SSL/HTTPS connector if a keystore is present
SslContextFactory.Server sslContextFactory = new SslContextFactory.Server();
sslContextFactory.setIncludeProtocols("SSLv3");
sslContextFactory.setExcludeProtocols("");
sslContextFactory.setIncludeCipherSuites("SSL_RSA_WITH_RC4_128_SHA", "SSL_RSA_WITH_RC4_128_MD5");
sslContextFactory.setExcludeCipherSuites("");
sslContextFactory.setKeyStore(keyStore);
sslContextFactory.setKeyStorePassword(keyStorePassword);
sslContextFactory.setSslSessionCacheSize(0);
sslContextFactory.setSslSessionTimeout(0);
HttpConfiguration httpsConfiguration = new HttpConfiguration();
httpsConfiguration.addCustomizer(new SecureRequestCustomizer(false));
httpsConfiguration.setSendServerVersion(true);
ServerConnector httpsConnector = new ServerConnector(server,
new SslConnectionFactory(sslContextFactory, HttpVersion.HTTP_1_1.asString()),
new HttpConnectionFactory(httpsConfiguration));
httpsConnector.setPort(sslPort);
server.addConnector(httpsConnector);
}
return server;
}
}

View File

@ -0,0 +1,246 @@
package entralinked.network.http.dashboard;
import java.util.Collections;
import java.util.stream.Collectors;
import com.fasterxml.jackson.databind.ObjectMapper;
import entralinked.Entralinked;
import entralinked.model.dlc.Dlc;
import entralinked.model.dlc.DlcList;
import entralinked.model.pkmn.PkmnGender;
import entralinked.model.pkmn.PkmnInfo;
import entralinked.model.player.DreamEncounter;
import entralinked.model.player.DreamItem;
import entralinked.model.player.Player;
import entralinked.model.player.PlayerManager;
import entralinked.model.player.PlayerStatus;
import entralinked.network.http.HttpHandler;
import entralinked.utility.GsidUtility;
import io.javalin.Javalin;
import io.javalin.config.JavalinConfig;
import io.javalin.http.Context;
import io.javalin.http.HttpStatus;
import io.javalin.http.staticfiles.Location;
import io.javalin.json.JavalinJackson;
/**
* HTTP handler for requests made to the user dashboard.
*/
public class DashboardHandler implements HttpHandler {
private final DlcList dlcList;
private final PlayerManager playerManager;
public DashboardHandler(Entralinked entralinked) {
this.dlcList = entralinked.getDlcList();
this.playerManager = entralinked.getPlayerManager();
}
@Override
public void addHandlers(Javalin javalin) {
javalin.get("/dashboard/dlc", this::handleRetrieveDlcList);
javalin.get("/dashboard/profile", this::handleRetrieveProfile);
javalin.post("/dashboard/profile", this::handleUpdateProfile);
javalin.post("/dashboard/login", this::handleLogin);
javalin.post("/dashboard/logout", this::handleLogout);
}
@Override
public void configureJavalin(JavalinConfig config) {
// Configure JSON mapper
config.jsonMapper(new JavalinJackson(new ObjectMapper()));
// Add dashboard pages
config.staticFiles.add(staticFileConfig -> {
staticFileConfig.location = Location.CLASSPATH;
staticFileConfig.directory = "/dashboard";
staticFileConfig.hostedPath = "/dashboard";
});
// Add sprites
config.staticFiles.add(staticFileConfig -> {
staticFileConfig.location = Location.CLASSPATH;
staticFileConfig.directory = "/sprites";
staticFileConfig.hostedPath = "/sprites";
});
}
/**
* GET request handler for {@code /dashboard/dlc}
*/
private void handleRetrieveDlcList(Context ctx) {
// Make sure that the DLC type is present
String type = ctx.queryParam("type");
if(type == null) {
ctx.json(Collections.EMPTY_LIST);
return;
}
// Send result
ctx.json(dlcList.getDlcList("IRAO", type).stream().map(Dlc::name).collect(Collectors.toList()));
}
/**
* GET request handler for {@code /dashboard/profile}
*/
private void handleRetrieveProfile(Context ctx) {
// Validate session
Player player = ctx.sessionAttribute("player");
if(player == null || player.getStatus() == PlayerStatus.AWAKE) {
ctx.json(new DashboardStatusMessage("Unauthorized", true));
ctx.status(HttpStatus.UNAUTHORIZED);
return;
}
// Send profile data
ctx.json(new DashboardProfileMessage(getSpritePath(player.getDreamerInfo()), player));
}
/**
* GET request handler for {@code /dashboard/login}
*/
private void handleLogin(Context ctx) {
String gsid = ctx.formParam("gsid");
// Check if the Game Sync ID is valid
if(gsid == null || !GsidUtility.isValidGameSyncId(gsid)) {
ctx.json(new DashboardStatusMessage("Please enter a valid Game Sync ID.", true));
return;
}
Player player = playerManager.getPlayer(gsid);
// Check if the Game Sync ID exists
if(player == null) {
ctx.json(new DashboardStatusMessage("This Game Sync ID does not exist.", true));
return;
}
// Check if there is stuff to play around with
if(player.getStatus() == PlayerStatus.AWAKE) {
ctx.json(new DashboardStatusMessage("Please use Game Sync to tuck in a Pokémon before proceeding.", true));
return;
}
// Store session attribute and send response
ctx.sessionAttribute("player", player);
ctx.json(new DashboardStatusMessage("ok")); // heh
}
/**
* POST request handler for {@code /dashboard/logout}
*/
private void handleLogout(Context ctx) {
// Who cares if the session actually exists? I sure don't.
ctx.consumeSessionAttribute("player");
ctx.json(Collections.EMPTY_MAP);
}
/**
* POST request handler for {@code /dashboard/profile}
*/
private void handleUpdateProfile(Context ctx) {
// Check if session exists
Player player = ctx.sessionAttribute("player");
if(player == null || player.getStatus() == PlayerStatus.AWAKE) {
ctx.json(new DashboardStatusMessage("Unauthorized", true));
ctx.status(HttpStatus.UNAUTHORIZED);
return;
}
// Validate request data
DashboardProfileUpdateRequest request = ctx.bodyAsClass(DashboardProfileUpdateRequest.class);
String error = validateProfileUpdateRequest(player, request);
if(error != null) {
ctx.json(new DashboardStatusMessage("Profile data was NOT saved: " + error, true));
return;
}
// Update profile
player.setStatus(PlayerStatus.WAKE_READY);
player.setEncounters(request.encounters());
player.setItems(request.items());
player.setCGearSkin(request.cgearSkin().equals("none") ? null : request.cgearSkin());
player.setDexSkin(request.dexSkin().equals("none") ? null : request.dexSkin());
player.setMusical(request.musical().equals("none") ? null : request.musical());
player.setLevelsGained(request.gainedLevels());
// Try to save profile data
if(!playerManager.savePlayer(player)) {
ctx.json(new DashboardStatusMessage("Profile data could not be saved because of an error.", true));
return;
}
// Send response if all succeeded
ctx.json(new DashboardStatusMessage("Your changes have been saved. Use Game Sync to wake up your Pokémon and download your selected content."));
}
/**
* Validates a {@link DashboardProfileUpdateRequest} and returns an error string if the data is invalid.
* If the data is valid, {@code null} is returned instead.
*/
private String validateProfileUpdateRequest(Player player, DashboardProfileUpdateRequest request) {
// Validate encounters
if(request.encounters().size() > 10) {
return "Encounter list size exceeds the limit.";
}
for(DreamEncounter encounter : request.encounters()) {
if(encounter.species() < 1 || encounter.species() > 493) {
return "Species is out of range.";
} else if(encounter.move() < 1 || encounter.move() > 559) {
return "Move ID is out of range.";
} else if(encounter.animation() == null) {
return "Animation is undefined.";
}
// TODO validate form maybe idk
}
// Validate items
if(request.items().size() > 20) {
return "Item list size exceeds the limit.";
}
for(DreamItem item : request.items()) {
if(item.id() < 0 || item.id() > 638) {
return "Item ID is out of range";
} else if(item.id() > 626 && !player.getGameVersion().isVersion2()) {
return "You have selected one or more items that are exclusive to Black Version 2 and White Version 2.";
}
if(item.quantity() < 0 || item.quantity() > 20) {
return "Item quantity is out of range.";
}
}
// Validate gained levels
if(request.gainedLevels() < 0 || request.gainedLevels() > 99) {
return "Gained levels is out of range.";
}
return null;
}
private String getSpritePath(PkmnInfo info) {
String basePath = "/sprites/pokemon/%s".formatted(info.isShiny() ? "shiny" : "normal");
String path = null;
if(info.form() > 0) {
path = "%s/%s-%s.png".formatted(basePath, info.species(), info.form());
} else if(info.gender() == PkmnGender.FEMALE) {
path = "%s/female/%s.png".formatted(basePath, info.species());
}
if(path == null || getClass().getResource(path) == null) {
return "%s/%s.png".formatted(basePath, info.species());
}
return path;
}
}

View File

@ -0,0 +1,25 @@
package entralinked.network.http.dashboard;
import java.util.Collection;
import entralinked.model.pkmn.PkmnInfo;
import entralinked.model.player.DreamEncounter;
import entralinked.model.player.DreamItem;
import entralinked.model.player.Player;
public record DashboardProfileMessage(
String gameVersion,
String dreamerSprite,
PkmnInfo dreamerInfo,
String cgearSkin,
String dexSkin,
String musical,
int levelsGained,
Collection<DreamEncounter> encounters,
Collection<DreamItem> items) {
public DashboardProfileMessage(String dreamerSprite, Player player) {
this(player.getGameVersion().getDisplayName(), dreamerSprite, player.getDreamerInfo(), player.getCGearSkin(),
player.getDexSkin(), player.getMusical(), player.getLevelsGained(), player.getEncounters(), player.getItems());
}
}

View File

@ -0,0 +1,17 @@
package entralinked.network.http.dashboard;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import entralinked.model.player.DreamEncounter;
import entralinked.model.player.DreamItem;
public record DashboardProfileUpdateRequest(
@JsonProperty(required = true) @JsonDeserialize(contentAs = DreamEncounter.class) List<DreamEncounter> encounters,
@JsonProperty(required = true) @JsonDeserialize(contentAs = DreamItem.class) List<DreamItem> items,
@JsonProperty(required = true) String cgearSkin,
@JsonProperty(required = true) String dexSkin,
@JsonProperty(required = true) String musical,
@JsonProperty(required = true) int gainedLevels) {}

View File

@ -0,0 +1,8 @@
package entralinked.network.http.dashboard;
public record DashboardStatusMessage(String message, boolean error) {
public DashboardStatusMessage(String message) {
this(message, false);
}
}

View File

@ -0,0 +1,106 @@
package entralinked.network.http.dls;
import java.io.FileInputStream;
import java.io.IOException;
import com.fasterxml.jackson.databind.ObjectMapper;
import entralinked.Entralinked;
import entralinked.model.dlc.Dlc;
import entralinked.model.dlc.DlcList;
import entralinked.model.user.ServiceSession;
import entralinked.model.user.UserManager;
import entralinked.network.http.HttpHandler;
import entralinked.network.http.HttpRequestHandler;
import entralinked.serialization.UrlEncodedFormFactory;
import entralinked.utility.LEOutputStream;
import io.javalin.Javalin;
import io.javalin.http.Context;
import io.javalin.http.HttpStatus;
/**
* HTTP handler for requests made to {@code dls1.nintendowifi.net}
*/
public class DlsHandler implements HttpHandler {
private final ObjectMapper mapper = new ObjectMapper(new UrlEncodedFormFactory());
private final DlcList dlcList;
private final UserManager userManager;
public DlsHandler(Entralinked entralinked) {
this.dlcList = entralinked.getDlcList();
this.userManager = entralinked.getUserManager();
}
@Override
public void addHandlers(Javalin javalin) {
javalin.post("/download", this::handleDownloadRequest);
}
/**
* POST base handler for {@code /download}
*/
private void handleDownloadRequest(Context ctx) throws IOException {
// Deserialize request body
DlsRequest request = mapper.readValue(ctx.body().replace("%2A", "*"), DlsRequest.class);
// Check if service session is valid
ServiceSession session = userManager.getServiceSession(request.serviceToken(), "dls1.nintendowifi.net");
if(session == null) {
ctx.status(HttpStatus.UNAUTHORIZED);
return;
}
// Determine handler function based on request action
HttpRequestHandler<DlsRequest> handler = switch(request.action()) {
case "list" -> this::handleRetrieveDlcList;
case "contents" -> this::handleRetrieveDlcContent;
default -> throw new IllegalArgumentException("Invalid POST request action: " + request.action());
};
// Handle the request
handler.process(request, ctx);
}
/**
* POST handler for {@code /download action=list}
*/
private void handleRetrieveDlcList(DlsRequest request, Context ctx) throws IOException {
// Map to generic type, I doubt there is a real difference between the language codes anyway.
String type = switch(request.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 -> request.dlcType();
};
// TODO NOTE: I assume that in a conventional implementation, certain DLC attributes may be omitted from the request.
ctx.result(dlcList.getDlcListString(dlcList.getDlcList(request.dlcGameCode(), type, request.dlcIndex())));
}
/**
* POST handler for {@code /download action=contents}
*/
private void handleRetrieveDlcContent(DlsRequest request, Context ctx) throws IOException {
// Check if the requested DLC exists
Dlc dlc = dlcList.getDlc(request.dlcName());
if(dlc == null) {
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());
}
}
}
}

View File

@ -0,0 +1,25 @@
package entralinked.network.http.dls;
import com.fasterxml.jackson.annotation.JsonProperty;
public record DlsRequest(
// Credentials
@JsonProperty(value = "userid", required = true) String userId,
@JsonProperty(value = "passwd", required = true) String password,
@JsonProperty(value = "macadr", required = true) String macAddress,
@JsonProperty(value = "token", required = true) String serviceToken,
// Game info
@JsonProperty("rhgamecd") String gameCode,
// Device info
@JsonProperty("apinfo") String accessPointInfo,
// Request-specific info
@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) {} // ?

View File

@ -0,0 +1,146 @@
package entralinked.network.http.nas;
import java.io.IOException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import entralinked.Configuration;
import entralinked.Entralinked;
import entralinked.model.user.ServiceCredentials;
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 io.javalin.Javalin;
import io.javalin.http.Context;
/**
* HTTP handler for requests made to {@code nas.nintendowifi.net}
*/
public class NasHandler implements HttpHandler {
private final ObjectMapper mapper = new ObjectMapper(new UrlEncodedFormFactory()).registerModule(new JavaTimeModule());
private final Configuration configuration;
private final UserManager userManager;
public NasHandler(Entralinked entralinked) {
this.configuration = entralinked.getConfiguration();
this.userManager = entralinked.getUserManager();
}
@Override
public void addHandlers(Javalin javalin) {
javalin.post("/ac", this::handleNasRequest);
}
/**
* POST base handler for {@code /ac}
* Deserializes requests and processes them accordingly.
*/
private void handleNasRequest(Context ctx) throws IOException {
// Deserialize body into a request object
NasRequest request = mapper.readValue(ctx.body(), NasRequest.class);
// Determine handler function based on request action
HttpRequestHandler<NasRequest> handler = switch(request.action()) {
case "login" -> this::handleLogin;
case "acctcreate" -> this::handleCreateAccount;
case "SVCLOC" -> this::handleRetrieveServiceLocation;
default -> throw new IllegalArgumentException("Invalid POST request action: " + request.action());
};
// Process the request
handler.process(request, ctx);
}
/**
* POST handler for {@code /ac action=login}
*/
private void handleLogin(NasRequest request, Context ctx) throws IOException {
// Make sure branch code is present
if(request.branchCode() == null) {
result(ctx, NasReturnCode.BAD_REQUEST);
return;
}
String userId = request.userId();
User user = userManager.authenticateUser(userId, request.password());
// Check if user exists
if(user == null) {
if(!configuration.allowWfcRegistrationThroughLogin()) {
result(ctx, NasReturnCode.USER_NOT_FOUND);
return;
}
// Try to register, if the configuration allows it
if(!UserManager.isValidUserId(userId) || userManager.doesUserExist(userId)
|| !userManager.registerUser(userId, request.password())) {
// Oh well, try again!
result(ctx, NasReturnCode.USER_NOT_FOUND);
return;
}
// Should *never* return null in this location
user = userManager.authenticateUser(userId, request.password());
}
// Prepare GameSpy server credentials
ServiceCredentials credentials = userManager.createServiceSession(user, "gamespy", request.branchCode());
result(ctx, new NasLoginResponse("gamespy.com", credentials.authToken(), credentials.challenge()));
}
/**
* POST handler for {@code /ac action=acctcreate}
*/
private void handleCreateAccount(NasRequest request, Context ctx) throws IOException {
String userId = request.userId();
// Check if user ID is invalid or duplicate
if(!UserManager.isValidUserId(userId) || userManager.doesUserExist(userId)) {
result(ctx, NasReturnCode.USER_ALREADY_EXISTS);
return;
}
// Try to register user
if(!userManager.registerUser(userId, request.password())) {
result(ctx, NasReturnCode.INTERNAL_SERVER_ERROR);
return;
}
result(ctx, NasReturnCode.REGISTRATION_SUCCESS);
}
/**
* POST handler for {@code /ac action=SVCLOC}
*/
private void handleRetrieveServiceLocation(NasRequest request, Context ctx) throws IOException {
// Determine service location from type
String service = switch(request.serviceType()) {
case "0000" -> "external"; // External game-specific service
case "9000" -> "dls1.nintendowifi.net"; // Download server
default -> throw new IllegalArgumentException("Invalid service type: " + request.serviceType());
};
// Prepare user credentials
ServiceCredentials credentials = userManager.createServiceSession(null, service, null);
result(ctx, new NasServiceLocationResponse(true, service, credentials.authToken()));
}
/**
* Sets context result to the specified response serialized as a URL encoded form.
*/
private void result(Context ctx, NasResponse response) throws IOException {
ctx.result(mapper.writeValueAsString(response));
}
/**
* Calls {@link #result(Context, NasResponse)} where {@code NasResponse} is a {@link NasStatusResponse}
* with the specified return code as its parameter.
*/
private void result(Context ctx, NasReturnCode returnCode) throws IOException {
result(ctx, new NasStatusResponse(returnCode));
}
}

View File

@ -0,0 +1,9 @@
package entralinked.network.http.nas;
import com.fasterxml.jackson.annotation.JsonProperty;
public record NasLoginResponse(
@JsonProperty("locator") String partner,
@JsonProperty("token") String partnerToken,
@JsonProperty("challenge") String partnerChallenge
) implements NasResponse {}

View File

@ -0,0 +1,33 @@
package entralinked.network.http.nas;
import java.time.LocalDateTime;
import java.time.MonthDay;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonFormat.Shape;
import com.fasterxml.jackson.annotation.JsonProperty;
public record NasRequest(
// Credentials
@JsonProperty(value = "userid", required = true) String userId,
@JsonProperty(value = "passwd", required = true) String password,
@JsonProperty(value = "macadr", required = true) String macAddress,
// Game info
@JsonProperty("gamecd") String gameCode,
@JsonProperty("makercd") String makerCode,
@JsonProperty("unitcd") String unitCode,
@JsonProperty("sdkver") String sdkVersion,
@JsonProperty("lang") String language,
// Device info
@JsonProperty("bssid") String bssid,
@JsonProperty("apinfo") String accessPointInfo,
@JsonProperty("devname") String deviceName,
@JsonProperty("devtime") @JsonFormat(shape = Shape.STRING, pattern = "yyMMddHHmmss") LocalDateTime deviceTime,
@JsonProperty("birth") @JsonFormat(shape = Shape.STRING, pattern = "MMdd") MonthDay birthDate,
// Request-specific info
@JsonProperty(value = "action", required = true) String action,
@JsonProperty("gsbrcd") String branchCode, // action=login
@JsonProperty("svc") String serviceType) {} // action=SVCLOC

View File

@ -0,0 +1,21 @@
package entralinked.network.http.nas;
import java.time.LocalDateTime;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonFormat.Shape;
public interface NasResponse {
@JsonProperty("returncd")
default NasReturnCode returnCode() {
return NasReturnCode.SUCCESS;
}
@JsonProperty("datetime")
@JsonFormat(shape = Shape.STRING, pattern = "yyMMddHHmmss")
default LocalDateTime dateTime() {
return LocalDateTime.now();
}
}

View File

@ -0,0 +1,61 @@
package entralinked.network.http.nas;
import com.fasterxml.jackson.annotation.JsonValue;
/**
* List of NAS return codes ({@code returncd})
*/
public enum NasReturnCode {
/**
* (Generic) Action was successful.
*/
SUCCESS(1),
/**
* (Generic) An error occured on the server whilst processing a request.
*/
INTERNAL_SERVER_ERROR(100),
/**
* (Registration) User account creation was successful.
* When this is sent, the user ID (WFC ID) will be stored on the client device.
*/
REGISTRATION_SUCCESS(2),
/**
* (Generic) Request is missing data.
*/
BAD_REQUEST(102),
/**
* (Registration) The client tried to register a user ID that already exists on the server.
* When this is sent, the client will generate a new user ID and try again.
*/
USER_ALREADY_EXISTS(104),
/**
* (Login) The user data has been deleted from the server.
*/
USER_EXPIRED(108),
/**
* (Login) The user with the client-specified user ID does not exist
*/
USER_NOT_FOUND(204);
private final int clientId;
private NasReturnCode(int clientId) {
this.clientId = clientId;
}
@JsonValue
public String getFormattedClientId() {
return "%03d".formatted(clientId);
}
public int getClientId() {
return clientId;
}
}

View File

@ -0,0 +1,14 @@
package entralinked.network.http.nas;
import com.fasterxml.jackson.annotation.JsonProperty;
public record NasServiceLocationResponse(
@JsonProperty("statusdata") char serviceStatus, // If 0x59 ('Y') sets flag to true. Game doesn't care when connecting to GL server, though.
@JsonProperty("svchost") String serviceHost,
@JsonProperty("servicetoken") String serviceToken
) implements NasResponse {
public NasServiceLocationResponse(boolean serviceAvailable, String serviceHost, String serviceToken) {
this(serviceAvailable ? 'Y' : 'N', serviceHost, serviceToken);
}
}

View File

@ -0,0 +1,3 @@
package entralinked.network.http.nas;
public record NasStatusResponse(NasReturnCode returnCode) implements NasResponse {}

View File

@ -0,0 +1,382 @@
package entralinked.network.http.pgl;
import java.io.IOException;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import com.fasterxml.jackson.databind.ObjectMapper;
import entralinked.Configuration;
import entralinked.Entralinked;
import entralinked.model.dlc.DlcList;
import entralinked.model.pkmn.PkmnInfo;
import entralinked.model.pkmn.PkmnInfoReader;
import entralinked.model.player.DreamEncounter;
import entralinked.model.player.DreamItem;
import entralinked.model.player.Player;
import entralinked.model.player.PlayerManager;
import entralinked.model.player.PlayerStatus;
import entralinked.model.user.ServiceSession;
import entralinked.model.user.UserManager;
import entralinked.network.http.HttpHandler;
import entralinked.network.http.HttpRequestHandler;
import entralinked.serialization.UrlEncodedFormFactory;
import entralinked.serialization.UrlEncodedFormParser;
import entralinked.utility.LEOutputStream;
import io.javalin.Javalin;
import io.javalin.http.Context;
import io.javalin.http.HttpStatus;
import io.javalin.security.BasicAuthCredentials;
import jakarta.servlet.ServletInputStream;
/**
* HTTP handler for requests made to {@code en.pokemon-gl.com}
*/
public class PglHandler implements HttpHandler {
private static final Logger logger = LogManager.getLogger();
private static final String username = "pokemon";
private static final String password = "2Phfv9MY"; // Best security in the world
private final ObjectMapper mapper = new ObjectMapper(new UrlEncodedFormFactory()
.disable(UrlEncodedFormParser.Feature.BASE64_DECODE_VALUES));
private final Set<Integer> sleepyList = new HashSet<>();
private final Configuration configuration;
private final DlcList dlcList;
private final UserManager userManager;
private final PlayerManager playerManager;
public PglHandler(Entralinked entralinked) {
this.configuration = entralinked.getConfiguration();
this.dlcList = entralinked.getDlcList();
this.userManager = entralinked.getUserManager();
this.playerManager = entralinked.getPlayerManager();
// Add all species to the sleepy list
for(int i = 1; i <= 649; i++) {
sleepyList.add(i);
}
}
@Override
public void addHandlers(Javalin javalin) {
javalin.before("/dsio/gw", this::authorizePglRequest);
javalin.get("/dsio/gw", this::handlePglGetRequest);
javalin.post("/dsio/gw", this::handlePglPostRequest);
}
/**
* BEFORE handler for {@code /dsio/gw} that serves to deserialize and authenticate the request.
* The deserialized request will be stored in a context attribute named {@code request} and may be retrieved
* by subsequent handlers.
*/
private void authorizePglRequest(Context ctx) throws IOException {
// Verify the authorization header credentials
BasicAuthCredentials credentials = ctx.basicAuthCredentials();
if(credentials == null ||
!username.equals(credentials.getUsername()) || !password.equals(credentials.getPassword())) {
ctx.status(HttpStatus.UNAUTHORIZED);
clearTasks(ctx);
return;
}
// Deserialize the request
PglRequest request = mapper.readValue(ctx.queryString(), PglRequest.class);
// Check game version
if(request.gameVersion() == null) {
ctx.status(HttpStatus.BAD_REQUEST);
clearTasks(ctx);
return;
}
// Verify the service session token
ServiceSession session = userManager.getServiceSession(request.token(), "external");
if(session == null) {
ctx.status(HttpStatus.UNAUTHORIZED);
clearTasks(ctx);
return;
}
// Store request object for subsequent handlers
ctx.attribute("request", request);
}
/**
* GET base handler for {@code /dsio/gw}
*/
private void handlePglGetRequest(Context ctx) throws IOException {
PglRequest request = ctx.attribute("request");
// Determine request handler function based on type
HttpRequestHandler<PglRequest> handler = switch(request.type()) {
case "sleepily.bitlist" -> this::handleGetSleepyList;
case "account.playstatus" -> this::handleGetAccountStatus;
case "savedata.download" -> this::handleDownloadSaveData;
default -> throw new IllegalArgumentException("Invalid GET request type: " + request.type());
};
// Handle the request
handler.process(request, ctx);
}
/**
* GET handler for {@code /dsio/gw?p=sleepily.bitlist}
*/
private void handleGetSleepyList(PglRequest request, Context ctx) throws IOException {
LEOutputStream outputStream = new LEOutputStream(ctx.outputStream());
// Check if player exists
if(!playerManager.doesPlayerExist(request.gameSyncId())) {
writeStatusCode(outputStream, 1); // Unauthorized
return;
}
// Create bitlist
byte[] bitlist = new byte[128]; // TODO pool? maybe just cache
for(int sleepy : sleepyList) {
// 8 Pokémon (bits) in 1 byte
int byteOffset = sleepy / 8;
int bitOffset = sleepy % 8;
// Set the bit to 1!
bitlist[byteOffset] |= 1 << bitOffset;
}
// Send bitlist
writeStatusCode(outputStream, 0);
outputStream.write(bitlist);
}
/**
* GET handler for {@code /dsio/gw?p=account.playstatus}
*
* Black 2 - {@code sub_21B74B4} (overlay #199)
*/
private void handleGetAccountStatus(PglRequest request, Context ctx) throws IOException {
LEOutputStream outputStream = new LEOutputStream(ctx.outputStream());
Player player = playerManager.getPlayer(request.gameSyncId());
// Request account creation if one doesn't exist yet
if(player == null) {
writeStatusCode(outputStream, 8); // 5 is also handled separately, but doesn't seem to do anything unique
return;
}
writeStatusCode(outputStream, 0);
outputStream.writeShort(player.getStatus().ordinal());
}
/**
* GET handler for {@code /dsio/gw?p=savedata.download}
*
* Black 2 - {@code sub_21B6C9C} (overlay #199)
*/
private void handleDownloadSaveData(PglRequest request, Context ctx) throws IOException {
LEOutputStream outputStream = new LEOutputStream(ctx.outputStream());
Player player = playerManager.getPlayer(request.gameSyncId());
// Check if player exists
if(player == null) {
writeStatusCode(outputStream, 1); // Unauthorized
return;
}
// 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;
}
List<DreamEncounter> encounters = player.getEncounters();
List<DreamItem> items = player.getItems();
// When waking up a Pokémon, these 4 bytes are written to 0x1D304 in the save file.
// If the bytes in the game's save file match the new bytes, they will be set to 0x00000000
// and no content will be downloaded.
outputStream.writeInt((int)(Math.random() * Integer.MAX_VALUE));
// Write encounter data (max 10)
for(DreamEncounter encounter : encounters) {
outputStream.writeShort(encounter.species());
outputStream.writeShort(encounter.move());
outputStream.write(encounter.form());
outputStream.write(0); // unknown
outputStream.write(encounter.animation().ordinal());
outputStream.write(0); // unknown
}
// Write encounter padding
outputStream.writeBytes(0, (10 - encounters.size()) * 8);
// Write misc stuff and DLC information
outputStream.writeShort(player.getLevelsGained());
outputStream.write(0); // Unknown
outputStream.write(dlcList.getDlcIndex(player.getMusical()));
outputStream.write(dlcList.getDlcIndex(player.getCGearSkin()));
outputStream.write(dlcList.getDlcIndex(player.getDexSkin()));
outputStream.write(0); // Unknown
outputStream.write(0); // Must be zero?
// Write item IDs
for(DreamItem item : items) {
outputStream.writeShort(item.id());
}
// Write item ID padding
outputStream.writeBytes(0, (20 - items.size()) * 2);
// Write item quantities
for(DreamItem item : items) {
outputStream.write(item.quantity()); // Hard caps at 20?
}
// Write quantity padding
outputStream.writeBytes(0, (20 - items.size()));
}
/**
* POST base handler for {@code /dsio/gw}
*/
private void handlePglPostRequest(Context ctx) throws IOException {
// Retrieve context attributes
PglRequest request = ctx.attribute("request");
// Determine handler function based on request type
HttpRequestHandler<PglRequest> handler = switch(request.type()) {
case "savedata.upload" -> this::handleUploadSaveData;
case "savedata.download.finish" -> this::handleDownloadSaveDataFinish;
case "account.create.upload" -> this::handleCreateAccount;
default -> throw new IllegalArgumentException("Invalid POST request type: " + request.type());
};
handler.process(request, ctx);
}
/**
* POST handler for {@code /dsio/gw?p=savedata.download.finish}
*/
private void handleDownloadSaveDataFinish(PglRequest request, Context ctx) throws IOException {
LEOutputStream outputStream = new LEOutputStream(ctx.outputStream());
Player player = playerManager.getPlayer(request.gameSyncId());
// Check if player exists
if(player == null) {
writeStatusCode(outputStream, 1); // Unauthorized
return;
}
// Reset player dream information if configured to do so
if(configuration.clearPlayerDreamInfoOnWake()) {
player.resetDreamInfo();
// Try to save player data
if(!playerManager.savePlayer(player)) {
logger.warn("Save data failure for player {}", player.getGameSyncId());
ctx.status(HttpStatus.INTERNAL_SERVER_ERROR);
}
}
// Write status code
writeStatusCode(outputStream, 0);
}
/**
* POST handler for {@code /dsio/gw?p=savedata.upload}
*/
private void handleUploadSaveData(PglRequest request, Context ctx) throws IOException {
// Read save data
ServletInputStream inputStream = ctx.req().getInputStream();
inputStream.skip(0x1D300); // Skip to dream world data
inputStream.skip(8); // Skip to Pokémon data
PkmnInfo info = PkmnInfoReader.readPokeInfo(inputStream);
// Don't care about anything else -- continue reading bytes until we're done.
while(!inputStream.isFinished()) {
inputStream.read();
}
// Prepare response
LEOutputStream outputStream = new LEOutputStream(ctx.outputStream());
Player player = playerManager.getPlayer(request.gameSyncId());
// Check if player exists
if(player == null) {
writeStatusCode(outputStream, 1); // Unauthorized
return;
}
// Check if player doesn't already have a Pokémon tucked in
if(player.getStatus() != PlayerStatus.AWAKE) {
logger.warn("Player {} tried to upload save data while already asleep", player.getGameSyncId());
// Return error if not allowed
if(!configuration.allowOverwritingPlayerDreamInfo()) {
writeStatusCode(outputStream, 4); // Already dreaming
return;
}
}
// Update and save player information
player.setStatus(PlayerStatus.SLEEPING);
player.setDreamerInfo(info);
if(!playerManager.savePlayer(player)) {
logger.warn("Save data failure for player {}", player.getGameSyncId());
ctx.status(HttpStatus.INTERNAL_SERVER_ERROR);
return;
}
// Send status code
writeStatusCode(outputStream, 0);
}
/**
* 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();
}
// Prepare response
LEOutputStream outputStream = new LEOutputStream(ctx.outputStream());
String gameSyncId = request.gameSyncId();
// Check if player doesn't exist already
if(playerManager.doesPlayerExist(gameSyncId)) {
writeStatusCode(outputStream, 2); // Duplicate Game Sync ID
return;
}
// Try to register player
if(playerManager.registerPlayer(gameSyncId, request.gameVersion()) == null) {
writeStatusCode(outputStream, 3); // Registration error
return;
}
// Write status code
writeStatusCode(outputStream, 0);
}
/**
* Writes the 4-byte status code and 124 empty bytes to the output stream.
*/
private void writeStatusCode(LEOutputStream outputStream, int status) throws IOException {
outputStream.writeInt(status);
outputStream.writeBytes(0, 124);
}
}

View File

@ -0,0 +1,20 @@
package entralinked.network.http.pgl;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import entralinked.GameVersion;
import entralinked.serialization.GsidDeserializer;
public record PglRequest(
@JsonProperty(value = "gsid", required = true) @JsonDeserialize(using = GsidDeserializer.class) String gameSyncId,
@JsonProperty(value = "p", required = true) String type,
@JsonProperty(value = "rom", required = true) int romCode,
@JsonProperty(value = "langcode", required = true) int languageCode,
@JsonProperty(value = "dreamw", required = true) int dreamWorld, // Always 1, but what is it for?
@JsonProperty(value = "tok", required = true) String token) {
public GameVersion gameVersion() {
return GameVersion.lookup(romCode(), languageCode());
}
}

View File

@ -0,0 +1,51 @@
package entralinked.serialization;
import java.io.ByteArrayInputStream;
import java.io.CharArrayReader;
import java.io.DataInput;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.core.JsonParseException;
import com.fasterxml.jackson.core.io.IOContext;
public class GameSpyMessageFactory extends JsonFactory {
private static final long serialVersionUID = -8313019676052328025L;
@Override
protected GameSpyMessageGenerator _createGenerator(Writer writer, IOContext context) throws IOException {
return new GameSpyMessageGenerator(_generatorFeatures, _objectCodec, writer);
}
@Override
protected GameSpyMessageGenerator _createUTF8Generator(OutputStream outputStream, IOContext context) throws IOException {
return _createGenerator(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8), context);
}
@Override
protected GameSpyMessageParser _createParser(InputStream inputStream, IOContext context) throws IOException {
return new GameSpyMessageParser(_parserFeatures, new InputStreamReader(inputStream));
}
@Override
protected GameSpyMessageParser _createParser(byte[] data, int offset, int length, IOContext context) throws IOException, JsonParseException {
return new GameSpyMessageParser(_parserFeatures, new InputStreamReader(new ByteArrayInputStream(data, offset, length)));
}
@Override
protected GameSpyMessageParser _createParser(char[] data, int offset, int length, IOContext context, boolean recyclable) throws IOException {
return new GameSpyMessageParser(_parserFeatures, new CharArrayReader(data, offset, length));
}
@Override
protected GameSpyMessageParser _createParser(DataInput input, IOContext context) throws IOException {
throw new UnsupportedOperationException();
}
}

View File

@ -0,0 +1,47 @@
package entralinked.serialization;
import java.io.IOException;
import java.io.Writer;
import com.fasterxml.jackson.core.ObjectCodec;
public class GameSpyMessageGenerator extends SimpleGeneratorBase {
protected final Writer writer;
public GameSpyMessageGenerator(int features, ObjectCodec codec, Writer writer) {
super(features, codec);
this.writer = writer;
}
@Override
public void writeEndObject() throws IOException {
_writeContext = _writeContext.getParent();
}
@Override
public void writeFieldName(String name) throws IOException {
writer.write('\\');
writer.write(name);
writer.write('\\');
}
@Override
public void writeString(String text) throws IOException {
writer.write(text);
}
@Override
public void flush() throws IOException {
writer.flush();
}
@Override
public void close() throws IOException {
try {
writer.close();
} finally {
super.close();
}
}
}

View File

@ -0,0 +1,105 @@
package entralinked.serialization;
import java.io.IOException;
import java.io.Reader;
import com.fasterxml.jackson.core.JsonToken;
public class GameSpyMessageParser extends SimpleParserBase {
private final Reader reader;
private String parsedString;
private boolean endOfInput;
private boolean closed;
public GameSpyMessageParser(int features, Reader reader) {
super(features);
this.reader = reader;
}
@Override
public JsonToken nextToken() throws IOException {
if(endOfInput) {
return _currToken = null; // Return null if there is no content left to read
}
if(_currToken == null) {
// TODO
skipBackSlash();
}
int i = parseString();
if(_currToken == JsonToken.VALUE_STRING || _currToken == null) {
// Parse field name
_currToken = JsonToken.FIELD_NAME;
if(parsedString.isEmpty()) {
_reportUnexpectedChar(i, "expected field name");
}
context.setCurrentName(parsedString);
if(i != '\\') {
_reportUnexpectedChar(i, "expected '\\' to close field name");
}
} else if(_currToken == JsonToken.FIELD_NAME) {
// Parse value string
_currToken = JsonToken.VALUE_STRING;
context.setCurrentValue(parsedString);
if(i != '\\') {
if(i != -1) {
_reportUnexpectedChar(i, "expected '\\' to open field name");
}
endOfInput = true; // We have reached the end, so let it be known!
}
}
return _currToken;
}
private int skipBackSlash() throws IOException {
int i = reader.read();
if(i != '\\') {
_reportUnexpectedChar(i, "expected '\\' to open field name");
}
return i;
}
private int parseString() throws IOException {
StringBuilder builder = new StringBuilder();
int i = -1;
while((i = reader.read()) != -1) {
if(i == '\\') {
break;
}
builder.append((char)i);
}
parsedString = builder.toString();
return i;
}
@Override
public void close() throws IOException {
if(!closed) {
try {
reader.close();
} finally {
closed = true;
}
}
}
@Override
public boolean isClosed() {
return closed;
}
}

View File

@ -0,0 +1,36 @@
package entralinked.serialization;
import java.io.IOException;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
import entralinked.utility.GsidUtility;
/**
* Deserializer that stringifies integers using {@link GsidUtility}
*/
public class GsidDeserializer extends StdDeserializer<String> {
private static final long serialVersionUID = -2973925169701434892L;
public GsidDeserializer() {
this(String.class);
}
protected GsidDeserializer(Class<?> type) {
super(type);
}
@Override
public String deserialize(JsonParser parser, DeserializationContext context) throws IOException {
int gsid = parser.getIntValue();
if(gsid < 0) {
throw new IOException("Game Sync ID cannot be a negative number.");
}
return GsidUtility.stringifyGameSyncId(gsid);
}
}

View File

@ -0,0 +1,131 @@
package entralinked.serialization;
import java.io.IOException;
import java.math.BigDecimal;
import java.math.BigInteger;
import com.fasterxml.jackson.core.Base64Variant;
import com.fasterxml.jackson.core.ObjectCodec;
import com.fasterxml.jackson.core.base.GeneratorBase;
/**
* Generator base for lazy implementations that simply write everything as strings.
* Keeps subclasses clean.
*/
public abstract class SimpleGeneratorBase extends GeneratorBase {
protected SimpleGeneratorBase(int features, ObjectCodec codec) {
super(features, codec);
}
@Override
protected void _releaseBuffers() {}
@Override
protected void _verifyValueWrite(String typeMsg) throws IOException {}
@Override
public void writeStartArray() throws IOException {
throw new UnsupportedOperationException("this format does not support arrays");
}
@Override
public void writeEndArray() throws IOException {
throw new UnsupportedOperationException("this format does not support arrays");
}
@Override
public void writeStartObject() throws IOException {
if(!_writeContext.inRoot()) {
throw new UnsupportedOperationException("this format does not support nested objects");
}
// Quirk
_writeContext = _writeContext.createChildObjectContext();
}
@Override
public void writeString(char[] buffer, int offset, int length) throws IOException {
writeString(new String(buffer, offset, length));
}
@Override
public void writeRawUTF8String(byte[] buffer, int offset, int length) throws IOException {
throw new UnsupportedOperationException();
}
@Override
public void writeUTF8String(byte[] buffer, int offset, int length) throws IOException {
throw new UnsupportedOperationException();
}
@Override
public void writeRaw(String text) throws IOException {
throw new UnsupportedOperationException();
}
@Override
public void writeRaw(String text, int offset, int len) throws IOException {
throw new UnsupportedOperationException();
}
@Override
public void writeRaw(char[] text, int offset, int length) throws IOException {
throw new UnsupportedOperationException();
}
@Override
public void writeRaw(char c) throws IOException {
writeString(String.valueOf(c));
}
@Override
public void writeBinary(Base64Variant variant, byte[] data, int offset, int length) throws IOException {
throw new UnsupportedOperationException();
}
@Override
public void writeNumber(int value) throws IOException {
writeString(String.valueOf(value));
}
@Override
public void writeNumber(long value) throws IOException {
writeString(String.valueOf(value));
}
@Override
public void writeNumber(BigInteger value) throws IOException {
writeString(value.toString());
}
@Override
public void writeNumber(double value) throws IOException {
writeString(String.valueOf(value));
}
@Override
public void writeNumber(float value) throws IOException {
writeString(String.valueOf(value));
}
@Override
public void writeNumber(BigDecimal value) throws IOException {
writeString(value.toPlainString());
}
@Override
public void writeNumber(String encodedValue) throws IOException {
writeString(encodedValue);
}
@Override
public void writeBoolean(boolean state) throws IOException {
writeString(String.valueOf(state));
}
@Override
public void writeNull() throws IOException {
writeString("null");
}
}

View File

@ -0,0 +1,158 @@
package entralinked.serialization;
import java.io.IOException;
import java.math.BigDecimal;
import java.math.BigInteger;
import com.fasterxml.jackson.core.Base64Variant;
import com.fasterxml.jackson.core.JsonLocation;
import com.fasterxml.jackson.core.JsonParseException;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.JsonStreamContext;
import com.fasterxml.jackson.core.ObjectCodec;
import com.fasterxml.jackson.core.Version;
import com.fasterxml.jackson.core.base.ParserMinimalBase;
import com.fasterxml.jackson.core.json.DupDetector;
import com.fasterxml.jackson.core.json.JsonReadContext;
/**
* Parser base for lazy implementations that simply parse everything as {@code VALUE_STRING}.
* Keeps subclasses clean.
*/
public abstract class SimpleParserBase extends ParserMinimalBase {
protected JsonReadContext context;
protected ObjectCodec codec;
public SimpleParserBase(int features) {
super(features);
DupDetector detector = isEnabled(JsonParser.Feature.STRICT_DUPLICATE_DETECTION) ? DupDetector.rootDetector(this) : null;
this.context = JsonReadContext.createRootContext(detector);
}
@Override
protected void _handleEOF() throws JsonParseException {}
@Override
public Number getNumberValue() throws IOException {
throw new UnsupportedOperationException();
}
@Override
public NumberType getNumberType() throws IOException {
throw new UnsupportedOperationException();
}
@Override
public int getIntValue() throws IOException {
return Integer.parseInt(getStringValue());
}
@Override
public long getLongValue() throws IOException {
return Long.parseLong(getStringValue());
}
@Override
public BigInteger getBigIntegerValue() throws IOException {
return new BigInteger(getStringValue());
}
@Override
public float getFloatValue() throws IOException {
return Float.parseFloat(getStringValue());
}
@Override
public double getDoubleValue() throws IOException {
return Double.parseDouble(getStringValue());
}
@Override
public BigDecimal getDecimalValue() throws IOException {
return new BigDecimal(getStringValue());
}
@Override
public byte[] getBinaryValue(Base64Variant variant) throws IOException {
throw new UnsupportedOperationException();
}
@Override
public JsonStreamContext getParsingContext() {
return context;
}
@Override
public void overrideCurrentName(String name) {
try {
context.setCurrentName(name);
} catch(JsonProcessingException e) {
throw new IllegalStateException(e);
}
}
@Override
public String getCurrentName() throws IOException {
return context.getCurrentName();
}
@Override
public String getText() throws IOException {
switch(_currToken) {
case FIELD_NAME: return context.getCurrentName();
case VALUE_STRING: return context.getCurrentValue().toString();
default: throw new IllegalStateException(); // Should not happen
}
}
@Override
public char[] getTextCharacters() throws IOException {
return getText().toCharArray();
}
@Override
public boolean hasTextCharacters() {
return true;
}
@Override
public int getTextLength() throws IOException {
return getText().length();
}
@Override
public int getTextOffset() throws IOException {
return 0;
}
@Override
public void setCodec(ObjectCodec codec) {
this.codec = codec;
}
@Override
public ObjectCodec getCodec() {
return codec;
}
@Override
public JsonLocation getCurrentLocation() {
return JsonLocation.NA;
}
@Override
public JsonLocation getTokenLocation() {
return JsonLocation.NA;
}
@Override
public Version version() {
return null;
}
public String getStringValue() {
return getCurrentValue() == null ? "null" : getCurrentValue().toString();
}
}

View File

@ -0,0 +1,101 @@
package entralinked.serialization;
import java.io.ByteArrayInputStream;
import java.io.CharArrayReader;
import java.io.DataInput;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.core.JsonParseException;
import com.fasterxml.jackson.core.io.IOContext;
public class UrlEncodedFormFactory extends JsonFactory {
private static final long serialVersionUID = -8313019676052328025L;
public static final int DEFAULT_FORMAT_GENERATOR_FEATURES = UrlEncodedFormGenerator.Feature.getDefaults();
public static final int DEFAULT_FORMAT_PARSER_FEATURES = UrlEncodedFormParser.Feature.getDefaults();
protected int formatGeneratorFeatures = DEFAULT_FORMAT_GENERATOR_FEATURES;
protected int formatParserFeatures = DEFAULT_FORMAT_PARSER_FEATURES;
@Override
protected UrlEncodedFormGenerator _createGenerator(Writer writer, IOContext context) throws IOException {
return new UrlEncodedFormGenerator(_generatorFeatures, formatGeneratorFeatures, _objectCodec, writer);
}
@Override
protected UrlEncodedFormGenerator _createUTF8Generator(OutputStream outputStream, IOContext context) throws IOException {
return _createGenerator(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8), context);
}
@Override
protected UrlEncodedFormParser _createParser(InputStream inputStream, IOContext context) throws IOException {
return new UrlEncodedFormParser(_parserFeatures, formatParserFeatures, new InputStreamReader(inputStream));
}
@Override
protected UrlEncodedFormParser _createParser(byte[] data, int offset, int length, IOContext context) throws IOException, JsonParseException {
return new UrlEncodedFormParser(_parserFeatures, formatParserFeatures, new InputStreamReader(new ByteArrayInputStream(data, offset, length)));
}
@Override
protected UrlEncodedFormParser _createParser(char[] data, int offset, int length, IOContext context, boolean recyclable) throws IOException {
return new UrlEncodedFormParser(_parserFeatures, formatParserFeatures, new CharArrayReader(data, offset, length));
}
@Override
protected UrlEncodedFormParser _createParser(DataInput input, IOContext context) throws IOException {
throw new UnsupportedOperationException();
}
@Override
public int getFormatGeneratorFeatures() {
return formatGeneratorFeatures;
}
@Override
public int getFormatParserFeatures() {
return formatParserFeatures;
}
public UrlEncodedFormFactory configure(UrlEncodedFormGenerator.Feature feature, boolean state) {
return state ? enable(feature) : disable(feature);
}
public UrlEncodedFormFactory enable(UrlEncodedFormGenerator.Feature feature) {
formatGeneratorFeatures |= feature.getMask();
return this;
}
public UrlEncodedFormFactory disable(UrlEncodedFormGenerator.Feature feature) {
formatGeneratorFeatures &= ~feature.getMask();
return this;
}
public final boolean isEnabled(UrlEncodedFormGenerator.Feature feature) {
return feature.enabledIn(formatGeneratorFeatures);
}
public UrlEncodedFormFactory configure(UrlEncodedFormParser.Feature feature, boolean state) {
return state ? enable(feature) : disable(feature);
}
public UrlEncodedFormFactory enable(UrlEncodedFormParser.Feature feature) {
formatParserFeatures |= feature.getMask();
return this;
}
public UrlEncodedFormFactory disable(UrlEncodedFormParser.Feature feature) {
formatParserFeatures &= ~feature.getMask();
return this;
}
public final boolean isEnabled(UrlEncodedFormParser.Feature feature) {
return feature.enabledIn(formatParserFeatures);
}
}

View File

@ -0,0 +1,130 @@
package entralinked.serialization;
import java.io.IOException;
import java.io.Writer;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import com.fasterxml.jackson.core.FormatFeature;
import com.fasterxml.jackson.core.ObjectCodec;
import com.fasterxml.jackson.core.json.JsonWriteContext;
/**
* Generator for URL encoded forms.
* I don't doubt its imperfection, but it does what it needs to do.
*/
public class UrlEncodedFormGenerator extends SimpleGeneratorBase {
/**
* Generator format features for form-data
*/
public enum Feature implements FormatFeature {
/**
* Whether values should be encoded as base64
*/
BASE64_ENCODE_VALUES(true);
private final boolean defaultState;
private final int mask;
private Feature(boolean defaultState) {
this.defaultState = defaultState;
this.mask = 1 << ordinal();
}
public static int getDefaults() {
int flags = 0;
for(Feature feature : values()) {
if(feature.enabledByDefault()) {
flags |= feature.getMask();
}
}
return flags;
}
@Override
public boolean enabledByDefault() {
return defaultState;
}
@Override
public int getMask() {
return mask;
}
@Override
public boolean enabledIn(int flags) {
return (flags & mask) != 0;
}
}
protected final int formatFeatures;
protected final Writer writer;
protected UrlEncodedFormGenerator(int features, int formatFeatures, ObjectCodec codec, Writer writer) {
super(features, codec);
this.formatFeatures = formatFeatures;
this.writer = writer;
}
@Override
public void writeEndObject() throws IOException {
_writeContext = _writeContext.getParent();
}
@Override
public void writeFieldName(String name) throws IOException {
int status = _writeContext.writeFieldName(name);
// Check if an entry separator should be appended before the field name
if(status == JsonWriteContext.STATUS_OK_AFTER_COMMA) {
writer.write('&');
}
writer.write(URLEncoder.encode(name, StandardCharsets.UTF_8));
}
@Override
public void writeString(String text) throws IOException {
int status = _writeContext.writeValue();
// Check if a key/value separator should be appended before the string value (should always be the case)
if(status == JsonWriteContext.STATUS_OK_AFTER_COLON) {
writer.write('=');
}
String value = text;
// Encode value as base64 if feature is enabled
if(Feature.BASE64_ENCODE_VALUES.enabledIn(formatFeatures)) {
value = Base64.getEncoder().encodeToString(text.getBytes(StandardCharsets.ISO_8859_1))
.replace('=', '*').replace('+', '.').replace('/', '-');
}
writer.write(URLEncoder.encode(value, StandardCharsets.UTF_8));
}
@Override
public int getFormatFeatures() {
return formatFeatures;
}
@Override
public void flush() throws IOException {
writer.flush();
}
@Override
public void close() throws IOException {
try {
writer.close();
} finally {
super.close();
}
}
}

View File

@ -0,0 +1,164 @@
package entralinked.serialization;
import java.io.IOException;
import java.io.Reader;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import com.fasterxml.jackson.core.FormatFeature;
import com.fasterxml.jackson.core.JsonToken;
/**
* Parser for URL encoded forms.
* It's not perfect, but it works well enough for the intended purpose.
*/
public class UrlEncodedFormParser extends SimpleParserBase {
/**
* Parser format features for form-data
*/
public enum Feature implements FormatFeature {
/**
* Whether values are encoded in base64 and should be decoded as such
*/
BASE64_DECODE_VALUES(true);
private final boolean defaultState;
private final int mask;
private Feature(boolean defaultState) {
this.defaultState = defaultState;
this.mask = 1 << ordinal();
}
public static int getDefaults() {
int flags = 0;
for(Feature feature : values()) {
if(feature.enabledByDefault()) {
flags |= feature.getMask();
}
}
return flags;
}
@Override
public boolean enabledByDefault() {
return defaultState;
}
@Override
public int getMask() {
return mask;
}
@Override
public boolean enabledIn(int flags) {
return (flags & mask) != 0;
}
}
private final Reader reader;
private final int formatFeatures;
private String parsedString;
private boolean closed;
private boolean endOfInput;
public UrlEncodedFormParser(int features, int formatFeatures, Reader reader) {
super(features);
this.formatFeatures = formatFeatures;
this.reader = reader;
}
@Override
public JsonToken nextToken() throws IOException {
if(endOfInput) {
return _currToken = null; // Return null if there is no content left to read
}
int i = parseStringAndSkipSeparator();
if(_currToken == JsonToken.VALUE_STRING || _currToken == null) {
// Parse field name
_currToken = JsonToken.FIELD_NAME;
if(parsedString.isEmpty()) {
_reportUnexpectedChar(i, "expected field name");
}
context.setCurrentName(URLDecoder.decode(parsedString, StandardCharsets.UTF_8));
if(i != '=') {
_reportUnexpectedChar(i, "expected '=' to mark end of key and start of value");
}
} else if(_currToken == JsonToken.FIELD_NAME) {
// Parse value string
_currToken = JsonToken.VALUE_STRING;
// Decode base64 if feature is enabled
if(Feature.BASE64_DECODE_VALUES.enabledIn(formatFeatures)) {
parsedString = new String(Base64.getDecoder().decode(
parsedString.replace('*', '=').replace('.', '+').replace('-', '/')), StandardCharsets.ISO_8859_1);
}
context.setCurrentValue(URLDecoder.decode(parsedString, StandardCharsets.UTF_8));
if(i != '&') {
if(i != -1) {
_reportUnexpectedChar(i, "expected '&' to mark end of value and start of new key");
}
endOfInput = true; // We have reached the end, so let it be known!
}
}
return _currToken;
}
/**
* Parses the next string and stores the output in {@link #parsedString}.
* The returned value is the first character read after the parsed string, or -1 if end-of-input.
*/
private int parseStringAndSkipSeparator() throws IOException {
StringBuilder builder = new StringBuilder();
int i = -1;
while((i = reader.read()) != -1) {
if(i == '&' || i == '=') {
break;
}
builder.append((char)i);
}
parsedString = builder.toString();
return i;
}
@Override
public void close() throws IOException {
if(!closed) {
try {
reader.close();
} finally {
closed = true;
}
}
}
@Override
public boolean isClosed() {
return closed;
}
@Override
public int getFormatFeatures() {
return formatFeatures;
}
}

View File

@ -0,0 +1,157 @@
package entralinked.utility;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.math.BigInteger;
import java.security.GeneralSecurityException;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.KeyStore;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.Security;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Base64;
import java.util.Calendar;
import java.util.zip.DataFormatException;
import java.util.zip.Inflater;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x509.AuthorityKeyIdentifier;
import org.bouncycastle.asn1.x509.Extension;
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
import org.bouncycastle.cert.X509v3CertificateBuilder;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cert.jcajce.JcaX509CertificateHolder;
import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.OperatorCreationException;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
/**
* Utility class for generating SSL certificates that are trusted by the DS.
*/
public class CertificateGenerator {
private static final Logger logger = LogManager.getLogger();
private static final String issuerCertificateString =
"eNp9lNuyqjgQhu95irm3dokoHi4TEk4aNBBAuAPRIOBZCPL0G5e1Zq+ZqZpcdqr/Tvf/dX796g/EhuX8pWGXWbqlAYbfwV8SsSw0ZZoGRksOhAUBt6BRAAfy8paXR2"
+ "MhZAiorwOkwQLhFQGlAUY+hjnRgoC0Eu6AC7kT9JlMq7J8p+TX9JTJqTJpLQT2n7sL02X1mp7dKj25jWUsThbWn/HWvkqRgrkXqgWhQmg8QgGlKyycNZNxqyPgfQ"
+ "QI034KYAcSzxIWiGxpeYmtvNk5gJZQz50i8mGdKrhOT1WdGUEdG/M6UhZPAidbxIBC0K4lBVZIR7t1cNlKiJF3UDjo76DgMW21Dtif6hEDVcCISwSmXy+0sLhuo6"
+ "1zSUL1LGXGoiYuEOjzfBOLzItD95kqakk8LEzxFV/iNl/3CXIcqmX6gk18hDANg5eUhE5u4aqOXpNW70Dw3TOqsmviwXUgE+6bdhNroP3u+2fb0v/2bRkHAmRD82"
+ "6GZ6VjRPHbUgAmhvO29UiXUOIUxaq8cRfEcrHijeaFOyPquizn6UQE7fi1TXYvmBeHyPM2bmovzVXhogl7ni+zk51OJPNg7LEiKy5bdA8gBlNTni6aod9MydZdDT"
+ "S+3AB9tqvjKfNJ4pbyKps14fO4qZMiDqJaKumJE81SqqOjWc0Qbs8g2bvuphuMcwzY4Kjfh+tNFWwYXy9ojxYF8DKxIOt6ev2HkLS3Na7MILAEQOABecXzksO8yT"
+ "VAZWy2UcbGsEorhzENmr0L1e6k925QTpXehcyoTm8nMsMX5kdsDWGE9dWRdObZEVSFNwXe6krfkNnwah6TrB+u+nbL9AiWeIoFT6ZAN59bv5u3dzNr54fRUuUL9H"
+ "CCgMZD7xIZ3tUyNn0iIfQhtA9QBhZ2IPkdZqTf1PeGaZAsA0XvEqOq47HbpAXmPcKfu5xs2R+beapEnIajStqdqiLyoJ2eSIsQWH5AejAgqxvKsE+g9SXQY7T6Kd"
+ "CPp7UKwKWfe69RHGLFyamhNpGn1n/QVYtUUfqhAuF85nSA2kSsELhJF+3I7UtUJqYr79ClWSnOK9X+nSw3/6B0bD++CZW+EYUfRCEwwmJiGDf3tDvYLpqfj+6Tne"
+ "Fw2ewOxmIyw3d4HFGrLx9yvk6Q5HpTlSgvbgO22k/vo0N43dO7utgfn/e4Xo9alIAktnewGqj5nRm75H7iaLEJBo983jJdugeXdp6y5X6sDmaDmyw/p91TKPrKmD"
+ "4ebbTY38D5WT67fONjW/X1OLNHg46GYYZa6etbxQ7671f7GypgwnI=";
private static final String issuerPrivateKeyString =
"eNod0Em2a0AAANAFGRBNYVgK0QalfWaIPproZfX/nH+XcG1dR4mnSxCaUu3JKUe5WLR1rND+Q+gwb3NO3ws5e0YXcydZcUtNV/35votzw9SsDstssI0TPxg5q1XPUq"
+ "EpGgfib4UnATQKiAcZHsBOsEWg2nShyhd7CoLQznBPWW/+iLfW3bMujf723htqG+n0p30h/SClZIRZibH7I5hGgQHRqgvpuJ/IDWpH9HQZelCC0xPK8uXZ87jcwv"
+ "Al64ufS7EqDw4HZfb6fi81sZvq02Xx84aBxLRvYg+ASqvaLsqPb9CrjrxW0koV02lUS9bWCSuQctd+t4w/3BwIjy0ZUdeOqEKzl95s4Sgcw66TQ55RWT5f78KalH"
+ "ncOiUv2Gk9VoV8tHw7sYC9RsH3m2xH6QeAFirI8+Qff3Lmmgrp3rqZbymH8skaH7Cku2uPPCe0xn2oP3uM7pOqtbvUKFyUyZ9tKqj7StW5zJ2UgvpYMeuc7INHAo"
+ "hV2CuQM0H0TU3f/wzd42bWlkx50Fc5eWTmBGCsMe95I05TreXUNFNLiisySELsXkZC8JNhR3KaLsmrWI4fa6iNpzrP1TOg/6StsTjERretEDEB7gZ28+mhQAs7T6"
+ "MzgI1W5a+hUvdFVGB43qZr5eOJ07g/FVIZ7xCYsGlcooISBawqUerOMyT4ivKwCC8XMn0wDNXSVb/+FtVXuzmmoQKx6C2OiW921ctlpI7+L7QPxl9RWjM8QsdL54"
+ "4sSHNmIW+3mVEWW8H9+3/s59kaKT97p4oFm+9Jc1hKrMu5/lhDrSe0QqBnwRnZx0jKRqdxHG7Z6lflnu0SfxCFKIwe2laBvNJzVHPlTayy/g/JGQy0";
private static X509Certificate issuerCertificate;
private static PrivateKey issuerPrivateKey;
private static boolean initialized;
public static void initialize() {
if(initialized) {
logger.warn("CertificateGenerator is already initialized!");
return;
}
System.setProperty("jdk.tls.useExtendedMasterSecret", "false"); // Will straight up not reuse SSL sessions if this is enabled
Security.setProperty("jdk.tls.disabledAlgorithms", "");
Security.setProperty("jdk.tls.legacyAlgorithms", "");
Security.insertProviderAt(new BouncyCastleProvider(), 1);
try {
issuerCertificate = (X509Certificate)CertificateFactory.getInstance("X.509").generateCertificate(
new ByteArrayInputStream(decodeAndDeflate(issuerCertificateString)));
} catch (CertificateException | DataFormatException e) {
logger.error("Could not create issuer certificate", e);
}
try {
byte[] keyBytes = Base64.getDecoder().decode(decodeAndDeflate(issuerPrivateKeyString));
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
issuerPrivateKey = keyFactory.generatePrivate(new PKCS8EncodedKeySpec(keyBytes));
} catch (NoSuchAlgorithmException | InvalidKeySpecException | DataFormatException e) {
logger.error("Could not create issuer private key", e);
}
initialized = true;
}
public static KeyStore generateCertificateKeyStore(String type, String password) throws GeneralSecurityException, IOException {
// Generate subject keys
KeyPairGenerator keyGenerator = KeyPairGenerator.getInstance("RSA");
keyGenerator.initialize(1024);
KeyPair keyPair = keyGenerator.generateKeyPair();
SubjectPublicKeyInfo publicKeyInfo = SubjectPublicKeyInfo.getInstance(keyPair.getPublic().getEncoded());
// Generate start/end dates
Calendar notBefore = Calendar.getInstance();
Calendar notAfter = Calendar.getInstance();
notAfter.add(Calendar.YEAR, 50);
// Voodoo because PKCS12 will cry that the DN doesn't match if the attributes aren't in the same order
X500Name issuerName = new JcaX509CertificateHolder(issuerCertificate).getSubject();
// Configure certificate builder
X509v3CertificateBuilder builder = new X509v3CertificateBuilder(
issuerName,
BigInteger.ONE,
notBefore.getTime(),
notAfter.getTime(),
new X500Name("CN=*.*.*"),
publicKeyInfo)
.addExtension(Extension.authorityKeyIdentifier, false, AuthorityKeyIdentifier.getInstance(
JcaX509ExtensionUtils.parseExtensionValue(issuerCertificate.getExtensionValue(Extension.authorityKeyIdentifier.getId()))));
// Sign certificate and create chain
ContentSigner signer = null;
try {
signer = new JcaContentSignerBuilder("SHA1withRSA").build(issuerPrivateKey);
} catch(OperatorCreationException e) {
// Delegate exception
throw new GeneralSecurityException(e);
}
X509Certificate subjectCertificate = new JcaX509CertificateConverter().getCertificate(builder.build(signer));
X509Certificate[] certificateChain = { subjectCertificate, issuerCertificate };
// And finally, create the keystore
KeyStore keyStore = KeyStore.getInstance(type);
keyStore.load(null, null);
keyStore.setCertificateEntry("server", subjectCertificate);
keyStore.setKeyEntry("server", keyPair.getPrivate(), password == null ? null : password.toCharArray(), certificateChain);
return keyStore;
}
private static byte[] decodeAndDeflate(String input) throws DataFormatException {
byte[] bytes = Base64.getDecoder().decode(input.getBytes());
Inflater inflater = new Inflater();
inflater.setInput(bytes);
byte[] buffer = new byte[2048];
byte[] output = new byte[inflater.inflate(buffer)];
System.arraycopy(buffer, 0, output, 0, output.length);
return output;
}
}

View File

@ -0,0 +1,61 @@
package entralinked.utility;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
import org.apache.logging.log4j.core.Appender;
import org.apache.logging.log4j.core.Core;
import org.apache.logging.log4j.core.Filter;
import org.apache.logging.log4j.core.Layout;
import org.apache.logging.log4j.core.LogEvent;
import org.apache.logging.log4j.core.appender.AbstractAppender;
import org.apache.logging.log4j.core.config.Property;
import org.apache.logging.log4j.core.config.plugins.Plugin;
import org.apache.logging.log4j.core.config.plugins.PluginAttribute;
import org.apache.logging.log4j.core.config.plugins.PluginElement;
import org.apache.logging.log4j.core.config.plugins.PluginFactory;
@Plugin(name = "ConsumerAppender",
category = Core.CATEGORY_NAME,
elementType = Appender.ELEMENT_TYPE,
printObject = true)
public class ConsumerAppender extends AbstractAppender {
protected static final Map<String, List<Consumer<String>>> consumerMap = new ConcurrentHashMap<>();
protected ConsumerAppender(String name, Filter filter, Layout<? extends Serializable> layout,
boolean ignoreExceptions, Property[] properties) {
super(name, filter, layout, ignoreExceptions, properties);
}
@PluginFactory
public static ConsumerAppender createAppender(
@PluginAttribute("name") String name,
@PluginElement("filter") Filter filter,
@PluginElement("Layout") Layout<? extends Serializable> layout,
@PluginAttribute("ignoreExceptions") boolean ignoreExceptions) {
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<>());
consumers.add(consumer);
consumerMap.putIfAbsent(appenderName, consumers);
}
@Override
public void append(LogEvent event) {
String formattedMessage = getLayout().toSerializable(event).toString();
List<Consumer<String>> consumers = consumerMap.get(getName());
if(consumers != null) {
for(Consumer<String> consumer : consumers) {
consumer.accept(formattedMessage);
}
}
}
}

View File

@ -0,0 +1,38 @@
package entralinked.utility;
/**
* Utility class for calculating CRC-16 checksums.
*/
public class Crc16 {
public static int calc(byte[] input, int offset, int length) {
int crc = 0xFFFF;
for(int i = offset; i < offset + length; i++) {
crc ^= (input[i] << 8);
for(int j = 0; j < 8; j++) {
if((crc & 0x8000) != 0) {
crc = crc << 1 ^ 0x1021;
} else {
crc <<= 1;
}
}
}
return crc & 0xFFFF;
}
public static int calc(byte[] input) {
return calc(input, 0, input.length);
}
public static int calc(int input) {
return calc(new byte[] {
(byte)(input & 0xFF),
(byte)((input >> 8) & 0xFF),
(byte)((input >> 16) & 0xFF),
(byte)((input >> 24) & 0xFF)
});
}
}

View File

@ -0,0 +1,35 @@
package entralinked.utility;
import java.security.SecureRandom;
import java.util.Base64;
/**
* Simple utility class for generating client credentials.
*/
public class CredentialGenerator {
public static final String CHALLENGE_CHARTABLE = "ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890";
private static final SecureRandom secureRandom = new SecureRandom();
/**
* @return A securely-generated server challenge of the specified length.
*/
public static String generateChallenge(int length) {
char[] challenge = new char[length];
for(int i = 0; i < challenge.length; i++) {
challenge[i] = CHALLENGE_CHARTABLE.charAt(secureRandom.nextInt(CHALLENGE_CHARTABLE.length()));
}
return new String(challenge);
}
/**
* @return A base64-encoded, securely-generated auth token of the specified length.
*/
public static String generateAuthToken(int length) {
byte[] bytes = new byte[length];
secureRandom.nextBytes(bytes);
return Base64.getUrlEncoder().encodeToString(bytes);
}
}

View File

@ -0,0 +1,42 @@
package entralinked.utility;
import java.util.regex.Pattern;
public class GsidUtility {
public static final String GSID_CHARTABLE = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
public static final Pattern GSID_PATTERN = Pattern.compile("[A-HJ-NP-Z2-9]{10}");
/**
* Stringifies the specified numerical Game Sync ID
*
* Black 2 - {@code sub_21B480C} (overlay #199)
*/
public static String stringifyGameSyncId(int gsid) {
char[] output = new char[10];
int index = 0;
// v12 = gsid
// v5 = sub_204405C(gsid, 4u)
// v8 = v5 + __CFSHR__(v12, 31) + (v12 >> 31)
// uses unsigned ints for bitshift operations
long ugsid = gsid;
long checksum = Crc16.calc(gsid); // + __CFSHR__(v12, 31) + (v12 >> 31); ??
// do while v4 < 10
for(int i = 0; i < output.length; i++) {
index = (int)((ugsid & 0x1F) & 0x1FFFF); // chartable string is unicode, so normally multiplies by 2
ugsid = (ugsid >> 5) | (checksum << 27);
checksum >>= 5;
output[i] = GSID_CHARTABLE.charAt(index); // sub_2048734(v4, chartable + index)
}
return new String(output);
}
public static boolean isValidGameSyncId(String gsid) {
return GSID_PATTERN.matcher(gsid).matches();
}
}

View File

@ -0,0 +1,58 @@
package entralinked.utility;
import java.io.FilterOutputStream;
import java.io.IOException;
import java.io.OutputStream;
/**
* Like {@link DataOutputStream}, but for little endian.
* It's comical how this is not in base Java yet.
*/
public class LEOutputStream extends FilterOutputStream {
protected final byte[] buffer = new byte[8];
public LEOutputStream(OutputStream outputStream) {
super(outputStream);
}
public void writeBytes(int value, int amount) throws IOException {
for(int i = 0; i < amount; i++) {
out.write(value);
}
}
public void writeShort(int value) throws IOException {
buffer[0] = (byte)(value & 0xFF);
buffer[1] = (byte)((value >> 8) & 0xFF);
out.write(buffer, 0, 2);
}
public void writeInt(int value) throws IOException {
buffer[0] = (byte)(value & 0xFF);
buffer[1] = (byte)((value >> 8) & 0xFF);
buffer[2] = (byte)((value >> 16) & 0xFF);
buffer[3] = (byte)((value >> 24) & 0xFF);
out.write(buffer, 0, 4);
}
public void writeFloat(float value) throws IOException {
writeInt(Float.floatToIntBits(value));
}
public void writeLong(long value) throws IOException {
buffer[0] = (byte)(value & 0xFF);
buffer[1] = (byte)((value >> 8) & 0xFF);
buffer[2] = (byte)((value >> 16) & 0xFF);
buffer[3] = (byte)((value >> 24) & 0xFF);
buffer[4] = (byte)((value >> 32) & 0xFF);
buffer[5] = (byte)((value >> 40) & 0xFF);
buffer[6] = (byte)((value >> 48) & 0xFF);
buffer[7] = (byte)((value >> 56) & 0xFF);
out.write(buffer, 0, 8);
}
public void writeDouble(double value) throws IOException {
writeLong(Double.doubleToLongBits(value));
}
}

View File

@ -0,0 +1,34 @@
package entralinked.utility;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import io.netty.util.internal.StringUtil;
/**
* Utility class for generating hex-formatted MD5 hashes.
*/
public class MD5 {
private static final Logger logger = LogManager.getLogger();
private static MessageDigest digest;
/**
* @return A hex-formatted MD5 hash of the specified input.
*/
public static String digest(String string) {
if(digest == null) {
try {
digest = MessageDigest.getInstance("MD5");
} catch(NoSuchAlgorithmException e) {
logger.error("Could not get MD5 MessageDigest instance", e);
}
}
return StringUtil.toHexStringPadded(digest.digest(string.getBytes(StandardCharsets.ISO_8859_1)));
}
}

View File

@ -0,0 +1,14 @@
<html>
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="styles/login.css">
</head>
<body>
<div class="root-container">
<label for="gsid">Game Sync ID</label><br>
<input type='text' id="gsid" name='gsid' placeholder='XXXXXXXXXX'>
<button id="login" onclick="postLogin()">Login</button>
</div>
</body>
<script src="scripts/login.js"></script>
</html>

View File

@ -0,0 +1,173 @@
<html>
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="styles/profile.css">
</head>
<body onload="fetchProfileData()">
<div id="main-container" class="root-container" style="display:none;">
<div>
<label id="game-summary" class="header-text"></label>
</div>
<div>
<!-- Dreamer Summary -->
<label>Tucked-in Pokémon Summary</label><br>
<table id="dreamer-summary" class="dreamer-summary">
<tr>
<td id="dreamer-sprite" class="dreamer-sprite" rowspan="5">
<image src="/sprites/pokemon/normal/0.png"/>
</td>
</tr>
<tr>
<th>Species</th>
<td id="dreamer-species"></td>
<th>Nature</th>
<td id="dreamer-nature"></td>
</tr>
<tr>
<th>Name</th>
<td id="dreamer-name"></td>
<th>Gender</th>
<td id="dreamer-gender"></td>
</tr>
<tr>
<th>Trainer</th>
<td id="dreamer-trainer"></td>
<th>Level</th>
<td id="dreamer-level"></td>
</tr>
<tr>
<th>Trainer ID</th>
<td id="dreamer-trainer-id"></td>
</tr>
</table>
</div>
<div>
<!-- Entree Forest Encounter Configuration -->
<label>Entree Forest Encounters (Max. 10)</label><br>
<div>
<table id="encounter-table" class="encounter-image-table">
<tr>
<td><a id="encounter0" href="#configureEncounter" onclick="configureEncounter(0)"><image src="/sprites/pokemon/normal/0.png"/></a></td>
<td><a id="encounter1" href="#configureEncounter" onclick="configureEncounter(1)"><image src="/sprites/pokemon/normal/0.png"/></a></td>
<td><a id="encounter2" href="#configureEncounter" onclick="configureEncounter(2)"><image src="/sprites/pokemon/normal/0.png"/></a></td>
<td><a id="encounter3" href="#configureEncounter" onclick="configureEncounter(3)"><image src="/sprites/pokemon/normal/0.png"/></a></td>
<td><a id="encounter4" href="#configureEncounter" onclick="configureEncounter(4)"><image src="/sprites/pokemon/normal/0.png"/></a></td>
</tr>
<tr>
<td><a id="encounter5" href="#configureEncounter" onclick="configureEncounter(5)"><image src="/sprites/pokemon/normal/0.png"/></a></td>
<td><a id="encounter6" href="#configureEncounter" onclick="configureEncounter(6)"><image src="/sprites/pokemon/normal/0.png"/></a></td>
<td><a id="encounter7" href="#configureEncounter" onclick="configureEncounter(7)"><image src="/sprites/pokemon/normal/0.png"/></a></td>
<td><a id="encounter8" href="#configureEncounter" onclick="configureEncounter(8)"><image src="/sprites/pokemon/normal/0.png"/></a></td>
<td><a id="encounter9" href="#configureEncounter" onclick="configureEncounter(9)"><image src="/sprites/pokemon/normal/0.png"/></a></td>
</tr>
</table>
</div>
</div>
<div>
<!-- Item Configuration -->
<label>Items (Max. 20)</label><br>
<div>
<table id="item-table" class="item-table">
<tr>
<td><a id="item0" href="#configureItem" onclick="configureItem(0)"><image src="/sprites/items/0.png"/></a></td>
<td><a id="item1" href="#configureItem" onclick="configureItem(1)"><image src="/sprites/items/0.png"/></a></td>
<td><a id="item2" href="#configureItem" onclick="configureItem(2)"><image src="/sprites/items/0.png"/></a></td>
<td><a id="item3" href="#configureItem" onclick="configureItem(3)"><image src="/sprites/items/0.png"/></a></td>
<td><a id="item4" href="#configureItem" onclick="configureItem(4)"><image src="/sprites/items/0.png"/></a></td>
<td><a id="item5" href="#configureItem" onclick="configureItem(5)"><image src="/sprites/items/0.png"/></a></td>
<td><a id="item6" href="#configureItem" onclick="configureItem(6)"><image src="/sprites/items/0.png"/></a></td>
<td><a id="item7" href="#configureItem" onclick="configureItem(7)"><image src="/sprites/items/0.png"/></a></td>
<td><a id="item8" href="#configureItem" onclick="configureItem(8)"><image src="/sprites/items/0.png"/></a></td>
<td><a id="item9" href="#configureItem" onclick="configureItem(9)"><image src="/sprites/items/0.png"/></a></td>
</tr>
<tr>
<td><a id="item10" href="#configureItem" onclick="configureItem(10)"><image src="/sprites/items/0.png"/></a></td>
<td><a id="item11" href="#configureItem" onclick="configureItem(11)"><image src="/sprites/items/0.png"/></a></td>
<td><a id="item12" href="#configureItem" onclick="configureItem(12)"><image src="/sprites/items/0.png"/></a></td>
<td><a id="item13" href="#configureItem" onclick="configureItem(13)"><image src="/sprites/items/0.png"/></a></td>
<td><a id="item14" href="#configureItem" onclick="configureItem(14)"><image src="/sprites/items/0.png"/></a></td>
<td><a id="item15" href="#configureItem" onclick="configureItem(15)"><image src="/sprites/items/0.png"/></a></td>
<td><a id="item16" href="#configureItem" onclick="configureItem(16)"><image src="/sprites/items/0.png"/></a></td>
<td><a id="item17" href="#configureItem" onclick="configureItem(17)"><image src="/sprites/items/0.png"/></a></td>
<td><a id="item18" href="#configureItem" onclick="configureItem(18)"><image src="/sprites/items/0.png"/></a></td>
<td><a id="item19" href="#configureItem" onclick="configureItem(19)"><image src="/sprites/items/0.png"/></a></td>
</tr>
</table>
</div>
</div>
<div>
<!-- Misc Configurations -->
<div class="grid-container">
<div>
<label>CGear Skin</label>
<select id="cgear-skin">
<option value="none">Do not change</option>
</select>
</div>
<div>
<label>Pokédex Skin</label>
<select id="dex-skin">
<option value="none">Do not change</option>
</select>
</div>
<div>
<label>Musical</label>
<select id="musical">
<option value="none">Do not change</option>
</select>
</div>
<div>
<label>Level Gain</label>
<input id="level-gain-input" type="number" value="0" min="0" max="99"/>
</div>
</div>
</div>
<div>
<button id="save" class="big-button" onclick="postProfileData()">Save Profile</button>
<button id="logout" class="big-button" onclick="postLogout()">Log Out</button>
</div>
</div>
<!-- Entree Forest Encounter Configuration Form -->
<div id="configureEncounter" class="popup">
<div class="content">
<button class="close-button" onclick="closeEncounterForm()">X</button>
<form id="encounter-form">
<label for="encounter-form-species">Species ID</label>
<input id="encounter-form-species" name="species" type="number" value="1" min="1" max="493"/>
<label for="encounter-form-move">Move ID</label>
<input id="encounter-form-move" name="move" type="number" value="1" min="1" max="559"/>
<label for="encounter-form-form">Forme Index</label>
<input id="encounter-form-form" name="form" type="number" value="0" min="0" max="31"/>
<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>
</div>
<!-- Item Configuration Form -->
<div id="configureItem" class="popup">
<div class="content">
<button class="close-button" onclick="closeEncounterForm()">X</button>
<form id="item-form">
<label for="item-form-id">Item ID</label>
<input id="item-form-id" name="id" type="number" value="1" min="1" max="638"/>
<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>
</body>
<script src="scripts/profile.js"></script>
</html>

View File

@ -0,0 +1,22 @@
const ELEMENT_GSID_INPUT = document.getElementById("gsid");
function postLogin() {
let loginData = {
gsid: ELEMENT_GSID_INPUT.value
}
fetch("/dashboard/login", {
method: "POST",
body: new URLSearchParams(loginData)
}).then((response) => {
return response.json();
}).then((response) => {
console.log(response);
if(response.error) {
alert(response.message);
} else {
window.location.href = "/dashboard/profile.html";
}
});
}

View File

@ -0,0 +1,365 @@
const ELEMENT_GAME_SUMMARY = document.getElementById("game-summary");
// Dreamer elements
const ELEMENT_DREAMER_SPRITE = document.getElementById("dreamer-sprite");
const ELEMENT_DREAMER_SPECIES = document.getElementById("dreamer-species");
const ELEMENT_DREAMER_NATURE = document.getElementById("dreamer-nature");
const ELEMENT_DREAMER_NAME = document.getElementById("dreamer-name");
const ELEMENT_DREAMER_GENDER = document.getElementById("dreamer-gender");
const ELEMENT_DREAMER_TRAINER = document.getElementById("dreamer-trainer");
const ELEMENT_DREAMER_TRAINER_ID = document.getElementById("dreamer-trainer-id");
const ELEMENT_DREAMER_LEVEL = document.getElementById("dreamer-level");
// Encounter form elements
const ELEMENT_ENCOUNTER_SPECIES = document.getElementById("encounter-form-species");
const ELEMENT_ENCOUNTER_MOVE = document.getElementById("encounter-form-move");
const ELEMENT_ENCOUNTER_FORM = document.getElementById("encounter-form-form");
const ELEMENT_ENCOUNTER_ANIMATION = document.getElementById("encounter-form-animation");
// Item form elements
const ELEMENT_ITEM_ID = document.getElementById("item-form-id");
const ELEMENT_ITEM_QUANTITY = document.getElementById("item-form-quantity");
// Misc input elements
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");
const ELEMENT_LEVEL_GAIN_INPUT = document.getElementById("level-gain-input");
// Create event listeners
ELEMENT_ENCOUNTER_SPECIES.addEventListener("change", clampValue);
ELEMENT_ENCOUNTER_MOVE.addEventListener("change", clampValue);
ELEMENT_ITEM_ID.addEventListener("change", clampValue);
ELEMENT_ITEM_QUANTITY.addEventListener("change", clampValue);
ELEMENT_LEVEL_GAIN_INPUT.addEventListener("change", clampValue);
function clampValue() {
let value = parseInt(this.value);
if(value < this.min) {
this.value = this.min;
} else if(value > this.max) {
console.log(value);
this.value = this.max;
}
}
// Local variables
var encounterTableIndex = -1;
var itemTableIndex = -1;
var profile = {
encounters: [],
items: []
};
function configureEncounter(index) {
encounterTableIndex = Math.min(10, Math.min(index, profile.encounters.length));
// Load existing settings
let encounter = profile.encounters[encounterTableIndex];
ELEMENT_ENCOUNTER_SPECIES.value = encounter ? encounter.species : 1;
ELEMENT_ENCOUNTER_MOVE.value = encounter ? encounter.move : 1;
ELEMENT_ENCOUNTER_FORM.value = encounter ? encounter.form : 0;
ELEMENT_ENCOUNTER_ANIMATION.value = encounter ? encounter.animation : "WALK_AROUND";
}
function saveEncounter() {
if(encounterTableIndex < 0) {
return;
}
// Create encounter data
let encounterData = {
species: ELEMENT_ENCOUNTER_SPECIES.value,
move: ELEMENT_ENCOUNTER_MOVE.value,
form: ELEMENT_ENCOUNTER_FORM.value,
animation: ELEMENT_ENCOUNTER_ANIMATION.value
}
// Set form to highest form available if it too great
let maxForm = 0;
switch(encounterData.species) {
case "201": maxForm = 27; break; // Unown
case "386": maxForm = 3; break; // Deoxys
case "412":
case "413": maxForm = 2; break; // Burmy & Wormadam
case "422":
case "423":
case "487": maxForm = 1; break; // Shellos, Gastrodon & Giratina
case "479": maxForm = 5; break; // Rotom
case "493": maxForm = 16; break; // Arceus
}
if(encounterData.form > maxForm) {
encounterData.form = maxForm;
}
profile.encounters[encounterTableIndex] = encounterData;
updateEncounterCell(encounterTableIndex);
closeEncounterForm();
}
function removeEncounter() {
if(encounterTableIndex < 0) {
return;
}
let oldLength = profile.encounters.length;
profile.encounters.splice(encounterTableIndex, 1);
for(let i = encounterTableIndex; i < oldLength; i++) {
updateEncounterCell(i);
}
closeEncounterForm();
}
function updateEncounterCell(index) {
let cell = document.getElementById("encounter" + index);
let encounterData = profile.encounters[index];
let spriteBase = "/sprites/pokemon/normal/";
let spriteImage = spriteBase + "0.png";
if(encounterData) {
spriteImage = spriteBase + encounterData.species + ".png";
if(encounterData.form > 0) {
let formSpriteImage = spriteBase + encounterData.species + "-" + encounterData.form + ".png";
if(checkURL(formSpriteImage)){
spriteImage = formSpriteImage;
}
}
}
cell.innerHTML = "<img src='" + spriteImage + "'/>";
}
function closeEncounterForm() {
encounterTableIndex = -1;
window.location.href = "#";
}
function configureItem(index) {
itemTableIndex = Math.min(20, Math.min(index, profile.items.length));
// Loadg existing settings
let item = profile.items[itemTableIndex];
ELEMENT_ITEM_ID.value = item ? item.id : 1;
ELEMENT_ITEM_QUANTITY.value = item ? item.quantity : 1;
}
function saveItem() {
if(itemTableIndex < 0) {
return;
}
let itemData = {
id: ELEMENT_ITEM_ID.value,
quantity: ELEMENT_ITEM_QUANTITY.value
}
profile.items[itemTableIndex] = itemData;
updateItemCell(itemTableIndex);
closeItemForm();
}
function removeItem() {
if(itemTableIndex < 0) {
return;
}
let oldLength = profile.items.length;
profile.items.splice(itemTableIndex, 1);
for(let i = itemTableIndex; i < oldLength; i++) {
updateItemCell(i);
}
closeItemForm();
}
function updateItemCell(index) {
let cell = document.getElementById("item" + index);
let item = profile.items[index];
let spriteBase = "/sprites/items/";
let spriteImage = spriteBase + "0.png";
let quantityStr = "";
if(item) {
let newSpriteImage = spriteBase + item.id + ".png";
quantityStr = "x" + item.quantity;
if(checkURL(newSpriteImage)){
spriteImage = newSpriteImage;
}
}
cell.innerHTML = "<img src='" + spriteImage + "'/><br>" + quantityStr;
}
function closeItemForm() {
itemTableIndex = -1;
window.location.href = "#";
}
async function fetchData(path) {
return fetchData(path, "GET", null);
}
async function fetchData(path, method, body) {
let response = await fetch(path, {
method: method,
body: body
});
// Return to login page if unauthorized
if(response.status == 401) {
window.location.href = "/dashboard/login.html";
return;
}
try {
return await response.json();
} catch(error) {
window.alert(error);
}
return null;
}
function fetchDlcData() {
let cgearType = profile.gameVersion.includes("2") ? "CGEAR2" : "CGEAR"; // Not a good way to do this!
// Fetch CGear skins
fetchData("/dashboard/dlc?type=" + cgearType).then((response) => {
addValuesToComboBox(ELEMENT_CGEAR_SKIN_INPUT, response);
ELEMENT_CGEAR_SKIN_INPUT.value = profile.cgearSkin;
});
// Fetch Dex skins
fetchData("/dashboard/dlc?type=ZUKAN").then((response) => {
addValuesToComboBox(ELEMENT_DEX_SKIN_INPUT, response);
ELEMENT_DEX_SKIN_INPUT.value = profile.dexSkin;
});
// Fetch musicals
fetchData("/dashboard/dlc?type=MUSICAL").then((response) => {
addValuesToComboBox(ELEMENT_MUSICAL_INPUT, response);
ELEMENT_MUSICAL_INPUT.value = profile.musical;
});
}
// TODO
function fetchProfileData() {
fetchData("/dashboard/profile").then((response) => {
let gameVersion = response["gameVersion"];
let dreamerSprite = response["dreamerSprite"];
let dreamerInfo = response["dreamerInfo"];
let encounters = response["encounters"];
let items = response["items"];
let cgearSkin = response["cgearSkin"];
let dexSkin = response["dexSkin"];
let musical = response["musical"];
let levelsGained = response["levelsGained"];
// Update game summary
profile.gameVersion = gameVersion;
ELEMENT_GAME_SUMMARY.innerHTML = "Game Card in use: " + gameVersion;
// Update dreamer summary
if(dreamerInfo) {
let species = dreamerInfo["species"];
let nature = dreamerInfo["nature"];
let nickname = dreamerInfo["nickname"];
let gender = dreamerInfo["gender"];
let trainerName = dreamerInfo["trainerName"];
let trainerId = dreamerInfo["trainerId"];
let level = dreamerInfo["level"];
// Set element values
ELEMENT_DREAMER_SPRITE.innerHTML = "<image src='" + dreamerSprite + "'/>";
ELEMENT_DREAMER_SPECIES.innerHTML = "#" + species;
ELEMENT_DREAMER_NATURE.innerHTML = stringToWord(nature);
ELEMENT_DREAMER_NAME.innerHTML = nickname;
ELEMENT_DREAMER_GENDER.innerHTML = stringToWord(gender);
ELEMENT_DREAMER_TRAINER.innerHTML = trainerName;
ELEMENT_DREAMER_TRAINER_ID.innerHTML = trainerId;
ELEMENT_DREAMER_LEVEL.innerHTML = level;
}
// Update encounter table
if(encounters){
profile.encounters = encounters;
for(let i = 0; i < 10; i++) {
updateEncounterCell(i);
}
}
// Update item table
if(items){
profile.items = items;
for(let i = 0; i < 20; i++) {
updateItemCell(i);
}
}
// Update selected DLC
profile.cgearSkin = cgearSkin ? cgearSkin : "none";
profile.dexSkin = dexSkin ? dexSkin : "none";
profile.musical = musical ? musical : "none";
fetchDlcData();
// Update level gain
ELEMENT_LEVEL_GAIN_INPUT.value = levelsGained;
// Show div
document.getElementById("main-container").style.display = "grid";
});
}
function postProfileData() {
// Construct body
let profileData = {
encounters: profile.encounters,
items: profile.items,
cgearSkin: ELEMENT_CGEAR_SKIN_INPUT.value,
dexSkin: ELEMENT_DEX_SKIN_INPUT.value,
musical: ELEMENT_MUSICAL_INPUT.value,
gainedLevels: ELEMENT_LEVEL_GAIN_INPUT.value
}
// Send data
fetchData("/dashboard/profile", "POST", JSON.stringify(profileData)).then((response) => {
alert(response.message);
});
}
function postLogout() {
fetchData("/dashboard/logout", "POST", null).then((response) => {
// Assume it succeeded
window.location.href = "/dashboard/login.html";
});
}
// TODO bad
function checkURL(url) {
var request = new XMLHttpRequest();
request.open('HEAD', url, false);
request.send();
return request.status == 200;
}
function stringToWord(string) {
return string.charAt(0).toUpperCase() + string.slice(1).toLowerCase();
}
function addValuesToComboBox(selectorElement, values) {
for(i in values) {
let value = values[i];
selectorElement.options[selectorElement.options.length] = new Option(value, value);
}
}

View File

@ -0,0 +1,38 @@
body {
background-color: #191919;
color: white;
font-size: 20px;
}
input {
color: white;
background-color: #262626;
border: 0px;
border-radius: 10px;
width: 100%;
margin-top: 4px;
margin-bottom: 16px;
padding: 4px 4px;
font-size: 16px;
}
button {
color: white;
background-color: #3D3D3D;
border: 0px;
border-radius: 10px;
font-size: 16px;
padding: 16px 32px;
}
button:active {
background-color: #333333;
}
.root-container {
position: absolute;
left: 50%;
top: 25%;
transform: translate(-50%, 0%);
padding: 10px;
}

View File

@ -0,0 +1,164 @@
body {
background-color: #191919;
color: white;
font-size: 20px;
}
form {
margin-bottom: 0px;
}
img {
image-rendering: pixelated;
}
input, select {
color: white;
background-color: #262626;
border: 0px;
border-radius: 10px;
width: 100%;
margin-top: 4px;
margin-bottom: 16px;
padding: 4px 4px;
font-size: 16px;
}
button {
color: white;
background-color: #3D3D3D;
border: 0px;
border-radius: 10px;
}
button:active {
background-color: #333333;
}
.root-container {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
padding: 10px;
max-width: 600px;
}
.grid-container {
display: grid;
grid-auto-columns: 1fr;
grid-auto-flow: column;
gap: 5px 10px;
}
.grid-container input {
width: 100%;
margin-top: 4px;
margin-bottom: 16px;
padding: 4px 4px;
font-size: 16px;
color: white;
}
.dreamer-summary {
width: 100%;
margin-top: 3px;
margin-bottom: 20px;
}
.dreamer-summary th {
text-align: left;
padding-left: 10px;
width: 64px;
}
.dreamer-summary td {
width: 96px;
background-color: #262626;
border-radius: 10px;
padding-left: 5px;
}
.dreamer-sprite {
text-align: center;
}
.header-text {
text-align: center;
font-weight: bold;
font-size: 24px;
margin-top: 0px;
}
.popup {
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
background: rgba(0, 0, 0, 0.6);
opacity: 0;
transition: opacity 200ms;
visibility: hidden;
}
.popup:target {
opacity: 1;
visibility: visible;
}
.popup .close-button {
position: absolute;
background-color: #3D3D3D;
top: 10px;
right: 10px;
}
.popup .content {
background-color: #191919;
position: absolute;
left: 50%;
top: 25%;
transform: translate(-50%, 0%);
padding: 20px;
width: 300px;
}
.encounter-image-table {
width: 100%;
border-spacing: 8px;
margin-bottom: 20px;
}
.encounter-image-table td {
background-color: #262626;
border-radius: 10px;
width: 96px;
height: 96px;
text-align: center;
}
.item-table {
width: 100%;
border-spacing: 8px;
margin-bottom: 20px;
}
.item-table td {
background-color: #262626;
border-radius: 10px;
width: 48px;
height: 48px;
text-align: center;
}
.item-table td a {
display: block;
color: inherit;
text-decoration: none;
}
.big-button {
font-size: 16px;
padding: 16px 32px;
}

Some files were not shown because too many files have changed in this diff Show More