diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..00a51af --- /dev/null +++ b/.gitattributes @@ -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 + diff --git a/.github/workflows/dist-pull-request.yml b/.github/workflows/dist-pull-request.yml new file mode 100644 index 0000000..c87d41f --- /dev/null +++ b/.github/workflows/dist-pull-request.yml @@ -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 diff --git a/.github/workflows/dist-upload-artifact.yml b/.github/workflows/dist-upload-artifact.yml new file mode 100644 index 0000000..1663acb --- /dev/null +++ b/.github/workflows/dist-upload-artifact.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..07da21d --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Gradle +.gradle +build +run +testRun + +# Eclipse +.metadata +.settings +.project +.classpath +bin diff --git a/README.md b/README.md index 48c0c09..7898881 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..532f975 --- /dev/null +++ b/build.gradle @@ -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 diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..6c7b331 --- /dev/null +++ b/gradle.properties @@ -0,0 +1 @@ +org.gradle.logging.level=info diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..249e583 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..fae0804 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..a69d9cb --- /dev/null +++ b/gradlew @@ -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" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..f127cfd --- /dev/null +++ b/gradlew.bat @@ -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 diff --git a/scripts/gradlew-clean.bat b/scripts/gradlew-clean.bat new file mode 100644 index 0000000..edb42d5 --- /dev/null +++ b/scripts/gradlew-clean.bat @@ -0,0 +1,3 @@ +@echo off +call ../gradlew clean -p .. --stacktrace +pause diff --git a/scripts/gradlew-dist.bat b/scripts/gradlew-dist.bat new file mode 100644 index 0000000..cd58368 --- /dev/null +++ b/scripts/gradlew-dist.bat @@ -0,0 +1,3 @@ +@echo off +call ../gradlew dist -p .. --stacktrace +pause diff --git a/scripts/gradlew-test.bat b/scripts/gradlew-test.bat new file mode 100644 index 0000000..ffa9fef --- /dev/null +++ b/scripts/gradlew-test.bat @@ -0,0 +1,3 @@ +@echo off +call ../gradlew test -p .. --stacktrace +pause diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..9ccf8f9 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'entralinked' diff --git a/src/main/java/entralinked/CommandLineArguments.java b/src/main/java/entralinked/CommandLineArguments.java new file mode 100644 index 0000000..764ec9a --- /dev/null +++ b/src/main/java/entralinked/CommandLineArguments.java @@ -0,0 +1,15 @@ +package entralinked; + +import java.util.Collection; +import java.util.List; + +public record CommandLineArguments(boolean disableGui) { + + public CommandLineArguments(Collection args) { + this(args.contains("disablegui")); + } + + public CommandLineArguments(String... args) { + this(List.of(args)); + } +} diff --git a/src/main/java/entralinked/Configuration.java b/src/main/java/entralinked/Configuration.java new file mode 100644 index 0000000..c22b607 --- /dev/null +++ b/src/main/java/entralinked/Configuration.java @@ -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); +} diff --git a/src/main/java/entralinked/Entralinked.java b/src/main/java/entralinked/Entralinked.java new file mode 100644 index 0000000..6e130a5 --- /dev/null +++ b/src/main/java/entralinked/Entralinked.java @@ -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; + } +} diff --git a/src/main/java/entralinked/GameVersion.java b/src/main/java/entralinked/GameVersion.java new file mode 100644 index 0000000..bd4a1e3 --- /dev/null +++ b/src/main/java/entralinked/GameVersion.java @@ -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 mapBySerial = new HashMap<>(); + private static final Map 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; + } +} diff --git a/src/main/java/entralinked/LauncherAgent.java b/src/main/java/entralinked/LauncherAgent.java new file mode 100644 index 0000000..bfbab49 --- /dev/null +++ b/src/main/java/entralinked/LauncherAgent.java @@ -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; + } +} diff --git a/src/main/java/entralinked/gui/MainView.java b/src/main/java/entralinked/gui/MainView.java new file mode 100644 index 0000000..5d8a6a2 --- /dev/null +++ b/src/main/java/entralinked/gui/MainView.java @@ -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(); + } + } + } +} diff --git a/src/main/java/entralinked/model/dlc/Dlc.java b/src/main/java/entralinked/model/dlc/Dlc.java new file mode 100644 index 0000000..8b576de --- /dev/null +++ b/src/main/java/entralinked/model/dlc/Dlc.java @@ -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) {} diff --git a/src/main/java/entralinked/model/dlc/DlcList.java b/src/main/java/entralinked/model/dlc/DlcList.java new file mode 100644 index 0000000..baa59c4 --- /dev/null +++ b/src/main/java/entralinked/model/dlc/DlcList.java @@ -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 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 getDlcList(Predicate filter) { + return getDlc().stream().filter(filter).collect(Collectors.toList()); + } + + public List getDlcList(String gameCode, String type, int index) { + return getDlcList(dlc -> + dlc.gameCode().equals(gameCode) && + dlc.type().equals(type) && + dlc.index() == index + ); + } + + public List getDlcList(String gameCode, String type) { + return getDlcList(dlc -> + dlc.gameCode().equals(gameCode) && + dlc.type().equals(type) + ); + } + + public List getDlcList(String gameCode) { + return getDlcList(dlc -> dlc.gameCode().equals(gameCode)); + } + + public String getDlcListString(Collection 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 getDlc() { + return Collections.unmodifiableCollection(dlcMap.values()); + } +} diff --git a/src/main/java/entralinked/model/pkmn/PkmnGender.java b/src/main/java/entralinked/model/pkmn/PkmnGender.java new file mode 100644 index 0000000..455756d --- /dev/null +++ b/src/main/java/entralinked/model/pkmn/PkmnGender.java @@ -0,0 +1,11 @@ +package entralinked.model.pkmn; + +import com.fasterxml.jackson.annotation.JsonEnumDefaultValue; + +public enum PkmnGender { + + @JsonEnumDefaultValue + MALE, + FEMALE, + GENDERLESS; +} diff --git a/src/main/java/entralinked/model/pkmn/PkmnInfo.java b/src/main/java/entralinked/model/pkmn/PkmnInfo.java new file mode 100644 index 0000000..bcfc595 --- /dev/null +++ b/src/main/java/entralinked/model/pkmn/PkmnInfo.java @@ -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; + } +} diff --git a/src/main/java/entralinked/model/pkmn/PkmnInfoReader.java b/src/main/java/entralinked/model/pkmn/PkmnInfoReader.java new file mode 100644 index 0000000..e2a07f5 --- /dev/null +++ b/src/main/java/entralinked/model/pkmn/PkmnInfoReader.java @@ -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); + } +} diff --git a/src/main/java/entralinked/model/pkmn/PkmnNature.java b/src/main/java/entralinked/model/pkmn/PkmnNature.java new file mode 100644 index 0000000..8bf4389 --- /dev/null +++ b/src/main/java/entralinked/model/pkmn/PkmnNature.java @@ -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; + } +} diff --git a/src/main/java/entralinked/model/player/DreamAnimation.java b/src/main/java/entralinked/model/player/DreamAnimation.java new file mode 100644 index 0000000..1abc427 --- /dev/null +++ b/src/main/java/entralinked/model/player/DreamAnimation.java @@ -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; + } +} diff --git a/src/main/java/entralinked/model/player/DreamEncounter.java b/src/main/java/entralinked/model/player/DreamEncounter.java new file mode 100644 index 0000000..44db730 --- /dev/null +++ b/src/main/java/entralinked/model/player/DreamEncounter.java @@ -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) {} diff --git a/src/main/java/entralinked/model/player/DreamItem.java b/src/main/java/entralinked/model/player/DreamItem.java new file mode 100644 index 0000000..95e7613 --- /dev/null +++ b/src/main/java/entralinked/model/player/DreamItem.java @@ -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) {} diff --git a/src/main/java/entralinked/model/player/Player.java b/src/main/java/entralinked/model/player/Player.java new file mode 100644 index 0000000..ba2e417 --- /dev/null +++ b/src/main/java/entralinked/model/player/Player.java @@ -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 encounters = new ArrayList<>(); + private final List 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 encounters) { + if(encounters.size() <= 10) { + this.encounters.clear(); + this.encounters.addAll(encounters); + } + } + + public List getEncounters() { + return Collections.unmodifiableList(encounters); + } + + public void setItems(Collection items) { + if(encounters.size() <= 20) { + this.items.clear(); + this.items.addAll(items); + } + } + + public List 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; + } +} diff --git a/src/main/java/entralinked/model/player/PlayerDto.java b/src/main/java/entralinked/model/player/PlayerDto.java new file mode 100644 index 0000000..c5f55c0 --- /dev/null +++ b/src/main/java/entralinked/model/player/PlayerDto.java @@ -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 encounters, + @JsonDeserialize(contentAs = DreamItem.class) Collection 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; + } +} diff --git a/src/main/java/entralinked/model/player/PlayerManager.java b/src/main/java/entralinked/model/player/PlayerManager.java new file mode 100644 index 0000000..2d0d299 --- /dev/null +++ b/src/main/java/entralinked/model/player/PlayerManager.java @@ -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 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 getPlayers() { + return Collections.unmodifiableCollection(playerMap.values()); + } +} diff --git a/src/main/java/entralinked/model/player/PlayerStatus.java b/src/main/java/entralinked/model/player/PlayerStatus.java new file mode 100644 index 0000000..1855174 --- /dev/null +++ b/src/main/java/entralinked/model/player/PlayerStatus.java @@ -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, +} diff --git a/src/main/java/entralinked/model/user/GameProfile.java b/src/main/java/entralinked/model/user/GameProfile.java new file mode 100644 index 0000000..32e40fc --- /dev/null +++ b/src/main/java/entralinked/model/user/GameProfile.java @@ -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; + } +} diff --git a/src/main/java/entralinked/model/user/GameProfileDto.java b/src/main/java/entralinked/model/user/GameProfileDto.java new file mode 100644 index 0000000..fd3ed5c --- /dev/null +++ b/src/main/java/entralinked/model/user/GameProfileDto.java @@ -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); + } +} diff --git a/src/main/java/entralinked/model/user/ServiceCredentials.java b/src/main/java/entralinked/model/user/ServiceCredentials.java new file mode 100644 index 0000000..85ad0e1 --- /dev/null +++ b/src/main/java/entralinked/model/user/ServiceCredentials.java @@ -0,0 +1,3 @@ +package entralinked.model.user; + +public record ServiceCredentials(String authToken, String challenge) {} diff --git a/src/main/java/entralinked/model/user/ServiceSession.java b/src/main/java/entralinked/model/user/ServiceSession.java new file mode 100644 index 0000000..2c919c2 --- /dev/null +++ b/src/main/java/entralinked/model/user/ServiceSession.java @@ -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); + } +} diff --git a/src/main/java/entralinked/model/user/User.java b/src/main/java/entralinked/model/user/User.java new file mode 100644 index 0000000..bd0a7a8 --- /dev/null +++ b/src/main/java/entralinked/model/user/User.java @@ -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 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 getProfiles() { + return Collections.unmodifiableCollection(profiles.values()); + } + + protected Map getProfileMap() { + return Collections.unmodifiableMap(profiles); + } +} diff --git a/src/main/java/entralinked/model/user/UserDto.java b/src/main/java/entralinked/model/user/UserDto.java new file mode 100644 index 0000000..f78c5d8 --- /dev/null +++ b/src/main/java/entralinked/model/user/UserDto.java @@ -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 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; + } +} diff --git a/src/main/java/entralinked/model/user/UserManager.java b/src/main/java/entralinked/model/user/UserManager.java new file mode 100644 index 0000000..39639d2 --- /dev/null +++ b/src/main/java/entralinked/model/user/UserManager.java @@ -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 users = new ConcurrentHashMap<>(); + private final Map profiles = new ConcurrentHashMap<>(); + private final Map 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 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 getUsers() { + return Collections.unmodifiableCollection(users.values()); + } +} diff --git a/src/main/java/entralinked/network/NettyServerBase.java b/src/main/java/entralinked/network/NettyServerBase.java new file mode 100644 index 0000000..5f45bd6 --- /dev/null +++ b/src/main/java/entralinked/network/NettyServerBase.java @@ -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; + } +} diff --git a/src/main/java/entralinked/network/dns/DnsQueryHandler.java b/src/main/java/entralinked/network/dns/DnsQueryHandler.java new file mode 100644 index 0000000..1f4f95b --- /dev/null +++ b/src/main/java/entralinked/network/dns/DnsQueryHandler.java @@ -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 { + + 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); + } +} diff --git a/src/main/java/entralinked/network/dns/DnsServer.java b/src/main/java/entralinked/network/dns/DnsServer.java new file mode 100644 index 0000000..6619638 --- /dev/null +++ b/src/main/java/entralinked/network/dns/DnsServer.java @@ -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() { + @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(); + } +} diff --git a/src/main/java/entralinked/network/gamespy/GameSpyHandler.java b/src/main/java/entralinked/network/gamespy/GameSpyHandler.java new file mode 100644 index 0000000..f992897 --- /dev/null +++ b/src/main/java/entralinked/network/gamespy/GameSpyHandler.java @@ -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 { + + 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 boolean setValue(Supplier valueSupplier, Consumer valueConsumer, Supplier 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(); + } +} diff --git a/src/main/java/entralinked/network/gamespy/GameSpyMessageEncoder.java b/src/main/java/entralinked/network/gamespy/GameSpyMessageEncoder.java new file mode 100644 index 0000000..5a80ff3 --- /dev/null +++ b/src/main/java/entralinked/network/gamespy/GameSpyMessageEncoder.java @@ -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 { + + 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); + } +} diff --git a/src/main/java/entralinked/network/gamespy/GameSpyRequestDecoder.java b/src/main/java/entralinked/network/gamespy/GameSpyRequestDecoder.java new file mode 100644 index 0000000..53b228d --- /dev/null +++ b/src/main/java/entralinked/network/gamespy/GameSpyRequestDecoder.java @@ -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 { + + protected final ObjectMapper mapper; + protected final Map> requestTypes; + + /** + * Supplied {@link ObjectMapper} should be configured to use the {@link GameSpyMessageFactory} + */ + public GameSpyRequestDecoder(ObjectMapper mapper, Map> requestTypes) { + this.mapper = mapper; + this.requestTypes = requestTypes; + } + + @Override + protected void decode(ChannelHandlerContext ctx, ByteBuf in, List 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 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(); + } +} \ No newline at end of file diff --git a/src/main/java/entralinked/network/gamespy/GameSpyServer.java b/src/main/java/entralinked/network/gamespy/GameSpyServer.java new file mode 100644 index 0000000..fb8eaea --- /dev/null +++ b/src/main/java/entralinked/network/gamespy/GameSpyServer.java @@ -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> 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 types = mapper.getSubtypeResolver().collectAndResolveSubtypesByClass(config, annotated); + + for(NamedType type : types) { + if(type.hasName()) { + requestTypes.put(type.getName(), (Class)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() { + @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(); + } +} diff --git a/src/main/java/entralinked/network/gamespy/message/GameSpyChallengeMessage.java b/src/main/java/entralinked/network/gamespy/message/GameSpyChallengeMessage.java new file mode 100644 index 0000000..d15fee4 --- /dev/null +++ b/src/main/java/entralinked/network/gamespy/message/GameSpyChallengeMessage.java @@ -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) {} diff --git a/src/main/java/entralinked/network/gamespy/message/GameSpyErrorMessage.java b/src/main/java/entralinked/network/gamespy/message/GameSpyErrorMessage.java new file mode 100644 index 0000000..a2c722e --- /dev/null +++ b/src/main/java/entralinked/network/gamespy/message/GameSpyErrorMessage.java @@ -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) {} diff --git a/src/main/java/entralinked/network/gamespy/message/GameSpyLoginResponse.java b/src/main/java/entralinked/network/gamespy/message/GameSpyLoginResponse.java new file mode 100644 index 0000000..168f270 --- /dev/null +++ b/src/main/java/entralinked/network/gamespy/message/GameSpyLoginResponse.java @@ -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) {} diff --git a/src/main/java/entralinked/network/gamespy/message/GameSpyMessage.java b/src/main/java/entralinked/network/gamespy/message/GameSpyMessage.java new file mode 100644 index 0000000..3a74952 --- /dev/null +++ b/src/main/java/entralinked/network/gamespy/message/GameSpyMessage.java @@ -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.. +} diff --git a/src/main/java/entralinked/network/gamespy/message/GameSpyProfileResponse.java b/src/main/java/entralinked/network/gamespy/message/GameSpyProfileResponse.java new file mode 100644 index 0000000..0409b7d --- /dev/null +++ b/src/main/java/entralinked/network/gamespy/message/GameSpyProfileResponse.java @@ -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); + } +} diff --git a/src/main/java/entralinked/network/gamespy/request/GameSpyKeepAliveRequest.java b/src/main/java/entralinked/network/gamespy/request/GameSpyKeepAliveRequest.java new file mode 100644 index 0000000..51e1a34 --- /dev/null +++ b/src/main/java/entralinked/network/gamespy/request/GameSpyKeepAliveRequest.java @@ -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 + } +} diff --git a/src/main/java/entralinked/network/gamespy/request/GameSpyLoginRequest.java b/src/main/java/entralinked/network/gamespy/request/GameSpyLoginRequest.java new file mode 100644 index 0000000..50501e7 --- /dev/null +++ b/src/main/java/entralinked/network/gamespy/request/GameSpyLoginRequest.java @@ -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); + } +} diff --git a/src/main/java/entralinked/network/gamespy/request/GameSpyLogoutRequest.java b/src/main/java/entralinked/network/gamespy/request/GameSpyLogoutRequest.java new file mode 100644 index 0000000..6b1fbcc --- /dev/null +++ b/src/main/java/entralinked/network/gamespy/request/GameSpyLogoutRequest.java @@ -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); + } +} diff --git a/src/main/java/entralinked/network/gamespy/request/GameSpyProfileRequest.java b/src/main/java/entralinked/network/gamespy/request/GameSpyProfileRequest.java new file mode 100644 index 0000000..6a818f8 --- /dev/null +++ b/src/main/java/entralinked/network/gamespy/request/GameSpyProfileRequest.java @@ -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); + } + } +} diff --git a/src/main/java/entralinked/network/gamespy/request/GameSpyProfileUpdateRequest.java b/src/main/java/entralinked/network/gamespy/request/GameSpyProfileUpdateRequest.java new file mode 100644 index 0000000..74ee0a8 --- /dev/null +++ b/src/main/java/entralinked/network/gamespy/request/GameSpyProfileUpdateRequest.java @@ -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); + } + } +} diff --git a/src/main/java/entralinked/network/gamespy/request/GameSpyRequest.java b/src/main/java/entralinked/network/gamespy/request/GameSpyRequest.java new file mode 100644 index 0000000..054e7fd --- /dev/null +++ b/src/main/java/entralinked/network/gamespy/request/GameSpyRequest.java @@ -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); +} diff --git a/src/main/java/entralinked/network/gamespy/request/GameSpyStatusRequest.java b/src/main/java/entralinked/network/gamespy/request/GameSpyStatusRequest.java new file mode 100644 index 0000000..a879edf --- /dev/null +++ b/src/main/java/entralinked/network/gamespy/request/GameSpyStatusRequest.java @@ -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); + } +} diff --git a/src/main/java/entralinked/network/http/HttpHandler.java b/src/main/java/entralinked/network/http/HttpHandler.java new file mode 100644 index 0000000..a5a7a11 --- /dev/null +++ b/src/main/java/entralinked/network/http/HttpHandler.java @@ -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(); + } +} diff --git a/src/main/java/entralinked/network/http/HttpRequestHandler.java b/src/main/java/entralinked/network/http/HttpRequestHandler.java new file mode 100644 index 0000000..38949f3 --- /dev/null +++ b/src/main/java/entralinked/network/http/HttpRequestHandler.java @@ -0,0 +1,11 @@ +package entralinked.network.http; + +import java.io.IOException; + +import io.javalin.http.Context; + +@FunctionalInterface +public interface HttpRequestHandler { + + public void process(T request, Context ctx) throws IOException; +} diff --git a/src/main/java/entralinked/network/http/HttpServer.java b/src/main/java/entralinked/network/http/HttpServer.java new file mode 100644 index 0000000..c583e32 --- /dev/null +++ b/src/main/java/entralinked/network/http/HttpServer.java @@ -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; + } +} diff --git a/src/main/java/entralinked/network/http/dashboard/DashboardHandler.java b/src/main/java/entralinked/network/http/dashboard/DashboardHandler.java new file mode 100644 index 0000000..da1fc25 --- /dev/null +++ b/src/main/java/entralinked/network/http/dashboard/DashboardHandler.java @@ -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; + } +} diff --git a/src/main/java/entralinked/network/http/dashboard/DashboardProfileMessage.java b/src/main/java/entralinked/network/http/dashboard/DashboardProfileMessage.java new file mode 100644 index 0000000..4b97788 --- /dev/null +++ b/src/main/java/entralinked/network/http/dashboard/DashboardProfileMessage.java @@ -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 encounters, + Collection 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()); + } +} diff --git a/src/main/java/entralinked/network/http/dashboard/DashboardProfileUpdateRequest.java b/src/main/java/entralinked/network/http/dashboard/DashboardProfileUpdateRequest.java new file mode 100644 index 0000000..90fc930 --- /dev/null +++ b/src/main/java/entralinked/network/http/dashboard/DashboardProfileUpdateRequest.java @@ -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 encounters, + @JsonProperty(required = true) @JsonDeserialize(contentAs = DreamItem.class) List items, + @JsonProperty(required = true) String cgearSkin, + @JsonProperty(required = true) String dexSkin, + @JsonProperty(required = true) String musical, + @JsonProperty(required = true) int gainedLevels) {} diff --git a/src/main/java/entralinked/network/http/dashboard/DashboardStatusMessage.java b/src/main/java/entralinked/network/http/dashboard/DashboardStatusMessage.java new file mode 100644 index 0000000..7f0321e --- /dev/null +++ b/src/main/java/entralinked/network/http/dashboard/DashboardStatusMessage.java @@ -0,0 +1,8 @@ +package entralinked.network.http.dashboard; + +public record DashboardStatusMessage(String message, boolean error) { + + public DashboardStatusMessage(String message) { + this(message, false); + } +} diff --git a/src/main/java/entralinked/network/http/dls/DlsHandler.java b/src/main/java/entralinked/network/http/dls/DlsHandler.java new file mode 100644 index 0000000..57ad52c --- /dev/null +++ b/src/main/java/entralinked/network/http/dls/DlsHandler.java @@ -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 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()); + } + } + } +} diff --git a/src/main/java/entralinked/network/http/dls/DlsRequest.java b/src/main/java/entralinked/network/http/dls/DlsRequest.java new file mode 100644 index 0000000..9d58016 --- /dev/null +++ b/src/main/java/entralinked/network/http/dls/DlsRequest.java @@ -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) {} // ? diff --git a/src/main/java/entralinked/network/http/nas/NasHandler.java b/src/main/java/entralinked/network/http/nas/NasHandler.java new file mode 100644 index 0000000..d557536 --- /dev/null +++ b/src/main/java/entralinked/network/http/nas/NasHandler.java @@ -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 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)); + } +} diff --git a/src/main/java/entralinked/network/http/nas/NasLoginResponse.java b/src/main/java/entralinked/network/http/nas/NasLoginResponse.java new file mode 100644 index 0000000..42ff380 --- /dev/null +++ b/src/main/java/entralinked/network/http/nas/NasLoginResponse.java @@ -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 {} diff --git a/src/main/java/entralinked/network/http/nas/NasRequest.java b/src/main/java/entralinked/network/http/nas/NasRequest.java new file mode 100644 index 0000000..40a9e50 --- /dev/null +++ b/src/main/java/entralinked/network/http/nas/NasRequest.java @@ -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 diff --git a/src/main/java/entralinked/network/http/nas/NasResponse.java b/src/main/java/entralinked/network/http/nas/NasResponse.java new file mode 100644 index 0000000..7a57681 --- /dev/null +++ b/src/main/java/entralinked/network/http/nas/NasResponse.java @@ -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(); + } +} diff --git a/src/main/java/entralinked/network/http/nas/NasReturnCode.java b/src/main/java/entralinked/network/http/nas/NasReturnCode.java new file mode 100644 index 0000000..02c79da --- /dev/null +++ b/src/main/java/entralinked/network/http/nas/NasReturnCode.java @@ -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; + } +} diff --git a/src/main/java/entralinked/network/http/nas/NasServiceLocationResponse.java b/src/main/java/entralinked/network/http/nas/NasServiceLocationResponse.java new file mode 100644 index 0000000..a6e22f7 --- /dev/null +++ b/src/main/java/entralinked/network/http/nas/NasServiceLocationResponse.java @@ -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); + } +} diff --git a/src/main/java/entralinked/network/http/nas/NasStatusResponse.java b/src/main/java/entralinked/network/http/nas/NasStatusResponse.java new file mode 100644 index 0000000..a0c67d8 --- /dev/null +++ b/src/main/java/entralinked/network/http/nas/NasStatusResponse.java @@ -0,0 +1,3 @@ +package entralinked.network.http.nas; + +public record NasStatusResponse(NasReturnCode returnCode) implements NasResponse {} diff --git a/src/main/java/entralinked/network/http/pgl/PglHandler.java b/src/main/java/entralinked/network/http/pgl/PglHandler.java new file mode 100644 index 0000000..d302fdf --- /dev/null +++ b/src/main/java/entralinked/network/http/pgl/PglHandler.java @@ -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 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 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 encounters = player.getEncounters(); + List 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 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); + } +} diff --git a/src/main/java/entralinked/network/http/pgl/PglRequest.java b/src/main/java/entralinked/network/http/pgl/PglRequest.java new file mode 100644 index 0000000..c7ff9ee --- /dev/null +++ b/src/main/java/entralinked/network/http/pgl/PglRequest.java @@ -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()); + } +} diff --git a/src/main/java/entralinked/serialization/GameSpyMessageFactory.java b/src/main/java/entralinked/serialization/GameSpyMessageFactory.java new file mode 100644 index 0000000..7ef1965 --- /dev/null +++ b/src/main/java/entralinked/serialization/GameSpyMessageFactory.java @@ -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(); + } +} \ No newline at end of file diff --git a/src/main/java/entralinked/serialization/GameSpyMessageGenerator.java b/src/main/java/entralinked/serialization/GameSpyMessageGenerator.java new file mode 100644 index 0000000..b37218c --- /dev/null +++ b/src/main/java/entralinked/serialization/GameSpyMessageGenerator.java @@ -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(); + } + } +} \ No newline at end of file diff --git a/src/main/java/entralinked/serialization/GameSpyMessageParser.java b/src/main/java/entralinked/serialization/GameSpyMessageParser.java new file mode 100644 index 0000000..fbc744c --- /dev/null +++ b/src/main/java/entralinked/serialization/GameSpyMessageParser.java @@ -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; + } +} diff --git a/src/main/java/entralinked/serialization/GsidDeserializer.java b/src/main/java/entralinked/serialization/GsidDeserializer.java new file mode 100644 index 0000000..8a1c296 --- /dev/null +++ b/src/main/java/entralinked/serialization/GsidDeserializer.java @@ -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 { + + 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); + } +} diff --git a/src/main/java/entralinked/serialization/SimpleGeneratorBase.java b/src/main/java/entralinked/serialization/SimpleGeneratorBase.java new file mode 100644 index 0000000..b27a2b9 --- /dev/null +++ b/src/main/java/entralinked/serialization/SimpleGeneratorBase.java @@ -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"); + } +} diff --git a/src/main/java/entralinked/serialization/SimpleParserBase.java b/src/main/java/entralinked/serialization/SimpleParserBase.java new file mode 100644 index 0000000..6e6709e --- /dev/null +++ b/src/main/java/entralinked/serialization/SimpleParserBase.java @@ -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(); + } +} diff --git a/src/main/java/entralinked/serialization/UrlEncodedFormFactory.java b/src/main/java/entralinked/serialization/UrlEncodedFormFactory.java new file mode 100644 index 0000000..9ad1d78 --- /dev/null +++ b/src/main/java/entralinked/serialization/UrlEncodedFormFactory.java @@ -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); + } +} diff --git a/src/main/java/entralinked/serialization/UrlEncodedFormGenerator.java b/src/main/java/entralinked/serialization/UrlEncodedFormGenerator.java new file mode 100644 index 0000000..65041cb --- /dev/null +++ b/src/main/java/entralinked/serialization/UrlEncodedFormGenerator.java @@ -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(); + } + } +} diff --git a/src/main/java/entralinked/serialization/UrlEncodedFormParser.java b/src/main/java/entralinked/serialization/UrlEncodedFormParser.java new file mode 100644 index 0000000..ae13258 --- /dev/null +++ b/src/main/java/entralinked/serialization/UrlEncodedFormParser.java @@ -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; + } +} diff --git a/src/main/java/entralinked/utility/CertificateGenerator.java b/src/main/java/entralinked/utility/CertificateGenerator.java new file mode 100644 index 0000000..2f13857 --- /dev/null +++ b/src/main/java/entralinked/utility/CertificateGenerator.java @@ -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; + } +} diff --git a/src/main/java/entralinked/utility/ConsumerAppender.java b/src/main/java/entralinked/utility/ConsumerAppender.java new file mode 100644 index 0000000..baaeb0b --- /dev/null +++ b/src/main/java/entralinked/utility/ConsumerAppender.java @@ -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>> consumerMap = new ConcurrentHashMap<>(); + + protected ConsumerAppender(String name, Filter filter, Layout 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 layout, + @PluginAttribute("ignoreExceptions") boolean ignoreExceptions) { + return new ConsumerAppender(name, filter, layout, ignoreExceptions, null); + } + + public static void addConsumer(String appenderName, Consumer consumer) { + List> 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> consumers = consumerMap.get(getName()); + + if(consumers != null) { + for(Consumer consumer : consumers) { + consumer.accept(formattedMessage); + } + } + } +} \ No newline at end of file diff --git a/src/main/java/entralinked/utility/Crc16.java b/src/main/java/entralinked/utility/Crc16.java new file mode 100644 index 0000000..d44de02 --- /dev/null +++ b/src/main/java/entralinked/utility/Crc16.java @@ -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) + }); + } +} diff --git a/src/main/java/entralinked/utility/CredentialGenerator.java b/src/main/java/entralinked/utility/CredentialGenerator.java new file mode 100644 index 0000000..c4d5511 --- /dev/null +++ b/src/main/java/entralinked/utility/CredentialGenerator.java @@ -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); + } +} diff --git a/src/main/java/entralinked/utility/GsidUtility.java b/src/main/java/entralinked/utility/GsidUtility.java new file mode 100644 index 0000000..c949d3b --- /dev/null +++ b/src/main/java/entralinked/utility/GsidUtility.java @@ -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(); + } +} diff --git a/src/main/java/entralinked/utility/LEOutputStream.java b/src/main/java/entralinked/utility/LEOutputStream.java new file mode 100644 index 0000000..d5283b0 --- /dev/null +++ b/src/main/java/entralinked/utility/LEOutputStream.java @@ -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)); + } +} diff --git a/src/main/java/entralinked/utility/MD5.java b/src/main/java/entralinked/utility/MD5.java new file mode 100644 index 0000000..845a02c --- /dev/null +++ b/src/main/java/entralinked/utility/MD5.java @@ -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))); + } +} diff --git a/src/main/resources/dashboard/login.html b/src/main/resources/dashboard/login.html new file mode 100644 index 0000000..681b2fb --- /dev/null +++ b/src/main/resources/dashboard/login.html @@ -0,0 +1,14 @@ + + + + + + +
+
+ + +
+ + + diff --git a/src/main/resources/dashboard/profile.html b/src/main/resources/dashboard/profile.html new file mode 100644 index 0000000..d6e6dde --- /dev/null +++ b/src/main/resources/dashboard/profile.html @@ -0,0 +1,173 @@ + + + + + + + + + + + + + + diff --git a/src/main/resources/dashboard/scripts/login.js b/src/main/resources/dashboard/scripts/login.js new file mode 100644 index 0000000..ba6f3d9 --- /dev/null +++ b/src/main/resources/dashboard/scripts/login.js @@ -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"; + } + }); +} diff --git a/src/main/resources/dashboard/scripts/profile.js b/src/main/resources/dashboard/scripts/profile.js new file mode 100644 index 0000000..28e3c0e --- /dev/null +++ b/src/main/resources/dashboard/scripts/profile.js @@ -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 = ""; +} + +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 = "
" + 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 = ""; + 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); + } +} diff --git a/src/main/resources/dashboard/styles/login.css b/src/main/resources/dashboard/styles/login.css new file mode 100644 index 0000000..dbfcb57 --- /dev/null +++ b/src/main/resources/dashboard/styles/login.css @@ -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; +} diff --git a/src/main/resources/dashboard/styles/profile.css b/src/main/resources/dashboard/styles/profile.css new file mode 100644 index 0000000..7e95003 --- /dev/null +++ b/src/main/resources/dashboard/styles/profile.css @@ -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; +} diff --git a/src/main/resources/dlc.paths b/src/main/resources/dlc.paths new file mode 100644 index 0000000..d123a61 --- /dev/null +++ b/src/main/resources/dlc.paths @@ -0,0 +1,44 @@ +/dlc/IRAO/CGEAR/C003_munna_1_en.bin +/dlc/IRAO/CGEAR/C004_pikachu_1_e.bin +/dlc/IRAO/CGEAR/C005_chirami_1_e.bin +/dlc/IRAO/CGEAR/C007_tabunne_1_e.bin +/dlc/IRAO/CGEAR/C008_pocchama_1_e.bin +/dlc/IRAO/CGEAR/C009_guregguru_1_e.bin +/dlc/IRAO/CGEAR/C010_fushigibana_1_e.bin +/dlc/IRAO/CGEAR/C011_rizadon_1_e.bin +/dlc/IRAO/CGEAR/C012_kamekkusu_1_e.bin +/dlc/IRAO/CGEAR/C013_zekurom_1_e_v02.bin +/dlc/IRAO/CGEAR/C014_reshiram_1_e.bin +/dlc/IRAO/CGEAR/C015_vikutini_1_e.bin +/dlc/IRAO/CGEAR/C017_porigonz_0_e.bin +/dlc/IRAO/CGEAR/C018_WCS_1_e.bin +/dlc/IRAO/CGEAR/C019_shikijikaharu_1_e.bin +/dlc/IRAO/CGEAR/C020_shikijikanatu_1_e.bin +/dlc/IRAO/CGEAR/C021_shikijikaaki_1_e.bin +/dlc/IRAO/CGEAR/C022_shikijikafuyu_1_e.bin +/dlc/IRAO/CGEAR/C023_iwaparesu_1_e.bin +/dlc/IRAO/CGEAR/C024_zoroark_1_e.bin +/dlc/IRAO/CGEAR/C025_giaru_1_e.bin +/dlc/IRAO/CGEAR/C030_kerudhio_1_en.bin +/dlc/IRAO/CGEAR/C031-1_meroetta_1_e.bin +/dlc/IRAO/CGEAR/C032-1_wcs2012_1_e.bin +/dlc/IRAO/CGEAR/C100_defo_1_en.bin +/dlc/IRAO/CGEAR2/C003-2_munna_1_en.bin +/dlc/IRAO/CGEAR2/C013-2_zekrom_1_e.bin +/dlc/IRAO/CGEAR2/C014-2_reshiram_1_e.bin +/dlc/IRAO/CGEAR2/C015-2_victini_1_e.bin +/dlc/IRAO/CGEAR2/C030-2_kerudhio_1_en.bin +/dlc/IRAO/CGEAR2/C031-2_meroetta_1_e.bin +/dlc/IRAO/CGEAR2/C033_halloween_1_en.bin +/dlc/IRAO/CGEAR2/C035_BK_1_e.bin +/dlc/IRAO/CGEAR2/C036_WK_1_e.bin +/dlc/IRAO/CGEAR2/C100-2_default_1_e.bin +/dlc/IRAO/MUSICAL/M010_munna_1_e_02.bin +/dlc/IRAO/MUSICAL/M013_meloetta_1_e.bin +/dlc/IRAO/ZUKAN/Z003_BWsyokigirl_1_en.bin +/dlc/IRAO/ZUKAN/Z004_BWsyokiboy_1_en.bin +/dlc/IRAO/ZUKAN/Z007_hyuu_1_en.bin +/dlc/IRAO/ZUKAN/Z008_bell_1_e.bin +/dlc/IRAO/ZUKAN/Z009_tyeren_1_e.bin +/dlc/IRAO/ZUKAN/Z100_defogirl_1_en.bin +/dlc/IRAO/ZUKAN/Z101_defoboy_1_en.bin diff --git a/src/main/resources/dlc/IRAO/CGEAR/C003_munna_1_en.bin b/src/main/resources/dlc/IRAO/CGEAR/C003_munna_1_en.bin new file mode 100644 index 0000000..75d1933 Binary files /dev/null and b/src/main/resources/dlc/IRAO/CGEAR/C003_munna_1_en.bin differ diff --git a/src/main/resources/dlc/IRAO/CGEAR/C004_pikachu_1_e.bin b/src/main/resources/dlc/IRAO/CGEAR/C004_pikachu_1_e.bin new file mode 100644 index 0000000..7a1dac0 Binary files /dev/null and b/src/main/resources/dlc/IRAO/CGEAR/C004_pikachu_1_e.bin differ diff --git a/src/main/resources/dlc/IRAO/CGEAR/C005_chirami_1_e.bin b/src/main/resources/dlc/IRAO/CGEAR/C005_chirami_1_e.bin new file mode 100644 index 0000000..d9bfc9b Binary files /dev/null and b/src/main/resources/dlc/IRAO/CGEAR/C005_chirami_1_e.bin differ diff --git a/src/main/resources/dlc/IRAO/CGEAR/C007_tabunne_1_e.bin b/src/main/resources/dlc/IRAO/CGEAR/C007_tabunne_1_e.bin new file mode 100644 index 0000000..4a9de6b Binary files /dev/null and b/src/main/resources/dlc/IRAO/CGEAR/C007_tabunne_1_e.bin differ diff --git a/src/main/resources/dlc/IRAO/CGEAR/C008_pocchama_1_e.bin b/src/main/resources/dlc/IRAO/CGEAR/C008_pocchama_1_e.bin new file mode 100644 index 0000000..6c24bf6 Binary files /dev/null and b/src/main/resources/dlc/IRAO/CGEAR/C008_pocchama_1_e.bin differ diff --git a/src/main/resources/dlc/IRAO/CGEAR/C009_guregguru_1_e.bin b/src/main/resources/dlc/IRAO/CGEAR/C009_guregguru_1_e.bin new file mode 100644 index 0000000..b9a1bac Binary files /dev/null and b/src/main/resources/dlc/IRAO/CGEAR/C009_guregguru_1_e.bin differ diff --git a/src/main/resources/dlc/IRAO/CGEAR/C010_fushigibana_1_e.bin b/src/main/resources/dlc/IRAO/CGEAR/C010_fushigibana_1_e.bin new file mode 100644 index 0000000..5a19e59 Binary files /dev/null and b/src/main/resources/dlc/IRAO/CGEAR/C010_fushigibana_1_e.bin differ diff --git a/src/main/resources/dlc/IRAO/CGEAR/C011_rizadon_1_e.bin b/src/main/resources/dlc/IRAO/CGEAR/C011_rizadon_1_e.bin new file mode 100644 index 0000000..467550b Binary files /dev/null and b/src/main/resources/dlc/IRAO/CGEAR/C011_rizadon_1_e.bin differ diff --git a/src/main/resources/dlc/IRAO/CGEAR/C012_kamekkusu_1_e.bin b/src/main/resources/dlc/IRAO/CGEAR/C012_kamekkusu_1_e.bin new file mode 100644 index 0000000..6ef6117 Binary files /dev/null and b/src/main/resources/dlc/IRAO/CGEAR/C012_kamekkusu_1_e.bin differ diff --git a/src/main/resources/dlc/IRAO/CGEAR/C013_zekurom_1_e_v02.bin b/src/main/resources/dlc/IRAO/CGEAR/C013_zekurom_1_e_v02.bin new file mode 100644 index 0000000..7957f3e Binary files /dev/null and b/src/main/resources/dlc/IRAO/CGEAR/C013_zekurom_1_e_v02.bin differ diff --git a/src/main/resources/dlc/IRAO/CGEAR/C014_reshiram_1_e.bin b/src/main/resources/dlc/IRAO/CGEAR/C014_reshiram_1_e.bin new file mode 100644 index 0000000..59b6bfc Binary files /dev/null and b/src/main/resources/dlc/IRAO/CGEAR/C014_reshiram_1_e.bin differ diff --git a/src/main/resources/dlc/IRAO/CGEAR/C015_vikutini_1_e.bin b/src/main/resources/dlc/IRAO/CGEAR/C015_vikutini_1_e.bin new file mode 100644 index 0000000..73cdfb3 Binary files /dev/null and b/src/main/resources/dlc/IRAO/CGEAR/C015_vikutini_1_e.bin differ diff --git a/src/main/resources/dlc/IRAO/CGEAR/C017_porigonz_0_e.bin b/src/main/resources/dlc/IRAO/CGEAR/C017_porigonz_0_e.bin new file mode 100644 index 0000000..ec84443 Binary files /dev/null and b/src/main/resources/dlc/IRAO/CGEAR/C017_porigonz_0_e.bin differ diff --git a/src/main/resources/dlc/IRAO/CGEAR/C018_WCS_1_e.bin b/src/main/resources/dlc/IRAO/CGEAR/C018_WCS_1_e.bin new file mode 100644 index 0000000..1b865f9 Binary files /dev/null and b/src/main/resources/dlc/IRAO/CGEAR/C018_WCS_1_e.bin differ diff --git a/src/main/resources/dlc/IRAO/CGEAR/C019_shikijikaharu_1_e.bin b/src/main/resources/dlc/IRAO/CGEAR/C019_shikijikaharu_1_e.bin new file mode 100644 index 0000000..cea8c7d Binary files /dev/null and b/src/main/resources/dlc/IRAO/CGEAR/C019_shikijikaharu_1_e.bin differ diff --git a/src/main/resources/dlc/IRAO/CGEAR/C020_shikijikanatu_1_e.bin b/src/main/resources/dlc/IRAO/CGEAR/C020_shikijikanatu_1_e.bin new file mode 100644 index 0000000..dce2860 Binary files /dev/null and b/src/main/resources/dlc/IRAO/CGEAR/C020_shikijikanatu_1_e.bin differ diff --git a/src/main/resources/dlc/IRAO/CGEAR/C021_shikijikaaki_1_e.bin b/src/main/resources/dlc/IRAO/CGEAR/C021_shikijikaaki_1_e.bin new file mode 100644 index 0000000..4973233 Binary files /dev/null and b/src/main/resources/dlc/IRAO/CGEAR/C021_shikijikaaki_1_e.bin differ diff --git a/src/main/resources/dlc/IRAO/CGEAR/C022_shikijikafuyu_1_e.bin b/src/main/resources/dlc/IRAO/CGEAR/C022_shikijikafuyu_1_e.bin new file mode 100644 index 0000000..8664eb5 Binary files /dev/null and b/src/main/resources/dlc/IRAO/CGEAR/C022_shikijikafuyu_1_e.bin differ diff --git a/src/main/resources/dlc/IRAO/CGEAR/C023_iwaparesu_1_e.bin b/src/main/resources/dlc/IRAO/CGEAR/C023_iwaparesu_1_e.bin new file mode 100644 index 0000000..0eef6da Binary files /dev/null and b/src/main/resources/dlc/IRAO/CGEAR/C023_iwaparesu_1_e.bin differ diff --git a/src/main/resources/dlc/IRAO/CGEAR/C024_zoroark_1_e.bin b/src/main/resources/dlc/IRAO/CGEAR/C024_zoroark_1_e.bin new file mode 100644 index 0000000..914605e Binary files /dev/null and b/src/main/resources/dlc/IRAO/CGEAR/C024_zoroark_1_e.bin differ diff --git a/src/main/resources/dlc/IRAO/CGEAR/C025_giaru_1_e.bin b/src/main/resources/dlc/IRAO/CGEAR/C025_giaru_1_e.bin new file mode 100644 index 0000000..5ab3de7 Binary files /dev/null and b/src/main/resources/dlc/IRAO/CGEAR/C025_giaru_1_e.bin differ diff --git a/src/main/resources/dlc/IRAO/CGEAR/C030_kerudhio_1_en.bin b/src/main/resources/dlc/IRAO/CGEAR/C030_kerudhio_1_en.bin new file mode 100644 index 0000000..aedea97 Binary files /dev/null and b/src/main/resources/dlc/IRAO/CGEAR/C030_kerudhio_1_en.bin differ diff --git a/src/main/resources/dlc/IRAO/CGEAR/C031-1_meroetta_1_e.bin b/src/main/resources/dlc/IRAO/CGEAR/C031-1_meroetta_1_e.bin new file mode 100644 index 0000000..703b83e Binary files /dev/null and b/src/main/resources/dlc/IRAO/CGEAR/C031-1_meroetta_1_e.bin differ diff --git a/src/main/resources/dlc/IRAO/CGEAR/C032-1_wcs2012_1_e.bin b/src/main/resources/dlc/IRAO/CGEAR/C032-1_wcs2012_1_e.bin new file mode 100644 index 0000000..f3df058 Binary files /dev/null and b/src/main/resources/dlc/IRAO/CGEAR/C032-1_wcs2012_1_e.bin differ diff --git a/src/main/resources/dlc/IRAO/CGEAR/C100_defo_1_en.bin b/src/main/resources/dlc/IRAO/CGEAR/C100_defo_1_en.bin new file mode 100644 index 0000000..fe503fb Binary files /dev/null and b/src/main/resources/dlc/IRAO/CGEAR/C100_defo_1_en.bin differ diff --git a/src/main/resources/dlc/IRAO/CGEAR2/C003-2_munna_1_en.bin b/src/main/resources/dlc/IRAO/CGEAR2/C003-2_munna_1_en.bin new file mode 100644 index 0000000..e2a8b40 Binary files /dev/null and b/src/main/resources/dlc/IRAO/CGEAR2/C003-2_munna_1_en.bin differ diff --git a/src/main/resources/dlc/IRAO/CGEAR2/C013-2_zekrom_1_e.bin b/src/main/resources/dlc/IRAO/CGEAR2/C013-2_zekrom_1_e.bin new file mode 100644 index 0000000..13c2704 Binary files /dev/null and b/src/main/resources/dlc/IRAO/CGEAR2/C013-2_zekrom_1_e.bin differ diff --git a/src/main/resources/dlc/IRAO/CGEAR2/C014-2_reshiram_1_e.bin b/src/main/resources/dlc/IRAO/CGEAR2/C014-2_reshiram_1_e.bin new file mode 100644 index 0000000..6e11faf Binary files /dev/null and b/src/main/resources/dlc/IRAO/CGEAR2/C014-2_reshiram_1_e.bin differ diff --git a/src/main/resources/dlc/IRAO/CGEAR2/C015-2_victini_1_e.bin b/src/main/resources/dlc/IRAO/CGEAR2/C015-2_victini_1_e.bin new file mode 100644 index 0000000..d5bf385 Binary files /dev/null and b/src/main/resources/dlc/IRAO/CGEAR2/C015-2_victini_1_e.bin differ diff --git a/src/main/resources/dlc/IRAO/CGEAR2/C030-2_kerudhio_1_en.bin b/src/main/resources/dlc/IRAO/CGEAR2/C030-2_kerudhio_1_en.bin new file mode 100644 index 0000000..e9c6e3b Binary files /dev/null and b/src/main/resources/dlc/IRAO/CGEAR2/C030-2_kerudhio_1_en.bin differ diff --git a/src/main/resources/dlc/IRAO/CGEAR2/C031-2_meroetta_1_e.bin b/src/main/resources/dlc/IRAO/CGEAR2/C031-2_meroetta_1_e.bin new file mode 100644 index 0000000..e176c52 Binary files /dev/null and b/src/main/resources/dlc/IRAO/CGEAR2/C031-2_meroetta_1_e.bin differ diff --git a/src/main/resources/dlc/IRAO/CGEAR2/C033_halloween_1_en.bin b/src/main/resources/dlc/IRAO/CGEAR2/C033_halloween_1_en.bin new file mode 100644 index 0000000..032233b --- /dev/null +++ b/src/main/resources/dlc/IRAO/CGEAR2/C033_halloween_1_en.bin @@ -0,0 +1,31 @@ +DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD4DDD4DDD4DDD4DDD4DDD4DDD4DDD4ADDDADDDADDDADDDADDDADDDADDDADDDDDDDDDDDDDDDDDDDDDDDDDDDADDDADDDADDDADDDADDDADDDADDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD4DDD4DDD4DDD4DDD4DDD4DDD4DDDDDDDDDDDDDDDDDDDDD3333DDDDDDDDDDDDDDDDDDDDDDDDDDDD3333DDD4DDD4DDD4DDD4DDD4DDD4DDD41333ADDDADDDADDDADDDADDDADDDADDD333DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDCDAD1DDDCAADDDDDDDDDDDDDDDDD4DDD4DDD4DDD4DDD41DD4AD4C4DDDDDDDDDDDDDDDDD̼̼܁DDDDDDDDDDDDDDDD4ADD4DD4AD4D4A44QffAffAffAffffefdfafffffffffffffFffffffffffffffffDD4AD4AD4D4D4D4A4DDDDDDD4DDDw4qwxwDDDDDDDD4DDDDDDDDD܁̌1A1DDDADDDCDDDDDDDDDDDDDDDCDD1AA3333ADDDDDDADDDDDDADDaf4QfDAfDfDeDaDQD4AfffffffVffVUffUffVeVfUfUVefUUfVeUUUfeUefVUffUeffUfffefffffffffffffVUffVeffUffVUffVeffUfffUffVeffDDDDDDDDDDDDDDDḎ́ﻈA1DDDDDDDDDDDDCDDDADDD1DDDDDDDDDDD4DD4AD4AD4AD4AD4AD4dVQU4eDAf4afefffaffefUeffUffVeffVeffUfffUffVeffVefffffffffffffffffffffffffVfffVfffVVeffVeffUfffUfffUfffefffefffefffDDADDDADDDADDDADDDADDADDADADDDDDĎ̪̌Ѽˋ̱DDDADDD4DDD4DDD4DDD4ADD4DD4DD4AD4ADAADAADQADaA4aAaAdAdfffUfffUfffUfffUfffUfffUfffUffVDffVffFffFffFffFffVfffdffQffVAefETUffefdfdfefffAffQff3CCefFAffefQfAfedafEDTfffUfffUfffUfffUfffUeffQffffffffffffffffffffffffVQffffEffFffVfffffffffffffffffVCCCCDCDCDCDD῱ADCDDD1DD1DD1D1CDCDCDCDCDCD4CDDDDDCDDADDADD1DDDDCDAD33DDDDDDDDDDDDDDADDDDDDDDDDDDDDDDDD῁DDD4DDDDDDDDDDDDDDDDDDDDDDDDDDDDAf6efaf4fDdD41DDDD4QdVAffVeffVefffUfafUefVeUfffUfffUfffUfffVeffVefffUfffUffDDDDDDDDADDCDD1DDDDDDDDDDDDADDAD4DDDDDDDDDDDDDDDDDDDD4DD4DDDD4DDD1333DDDDDDDDD4DDD4DDD4DDD4DDD4AAAD333DD4DD4DD4DD4DD4AD4AD43333DDDDDDDDDDDDDDDDD4DDDD3DDDDDDDD4DDD4DDD4DDD4DDD43UA4ADDDADDDADDDADDDUUffDDD3DDDDDDDDDDDD33CCADADCDDCDDDCDDDA4AAAAAAA43CD4DDD4DDD413ADDDADDDADDDADDDDD3DDDDDDDDDDDDDDDDDDDDDDDD4D444DD4CDCD4CDDDDDDDCDD4DDDDDDDDCDDDDDDDDDDDCDDDCDDDCDDDCDDDDDDDDDDDDDDDD1DDDCD133343C41D1DDDADDDCCDDDDDDDDDDDDDD1AA4DDD1efAffQffaffaffdffdffeff3VAfFffffFffffffffff3333DDDDDDDDADDDDDDADDDDCD3333DDDDDDDDDDDDDDD4DDDDDDDDD33333CDDDADDDADDDADDD1DDDDDDDDDeffeffeffdffdffaffaffaffffffffffffffffffffffffffffffffffADFDfAfAfFffffffFDDD4DDD4DDD4DDD4DDD4DDD4ADD4DD4DDDDD4DDDDDDDDDDDDCD1DA4ADDDDDDDDDDCDDD1DDCC1DV13fDfDffffFAfETe3333DDAUAeffeffVfffU{wwwwwwwfUefUeffUfffefff333DDDDDDDDDDс3̱̼̪̽́1̼̼3333ADDDCDDADDDDAD1DDDDD4DDD4DDDDDDDDDDDDDDDDDDCADDD4DDD4DDD4DDD4ADD4ADD4DD4DD4DDDDDDDD4DDDDDDDDDDDD4ffffdfffafffAffffffdffQffff33DDD4DDD4DDD4DDD4ADD4CD4D433ACDDADDADDADDADDADDADD333DDDDDDDDDDDDDDDDDDDDDDDDDD133ADDADDDDDDDDDDD3333DDDDDDDDDDDDDDDDDDDDADDDADD133ADDADDAD4ADADADADcffeffAfffQffVaffVdffVeffVfffVUfffUfffUfffefffefffeffVeffefffffVfffUfffUfEDTEefEQffVafffafffefffffffffffffffQfffeffQffAff߱?1331DDADDCDDDDDDDD1DDDADDD3333DDDD1DDD1DDC3333DDDDDDDDDDDDDDDD1DDDCD1ADADADCDCCCAC1CCC4CDDDDDDDDDDDDDDDDDDDDDCDDDADDD1DDDDDD4DDD4DDD4DDD4DDDDDDDDDAǍ̱D4D4A4A4444AdAdAdAaA4aADQADQfffffVfFfFfFfFfVC1DD31D4CDD4DDD4DDD4133DDDDDD4DDDDDDDDDDDDfVefdfafQffFefafQAAfffUfffUfffVfffVfffVfffVfffVfffVfcfVefeffefffefffefffeffffVTAeAefffffffffffffffffDDDDDD1333CCDC1DDADDDADDDADDDADDDADDDADDDD4DDDDDDDDDDDDDDDDDDDDDDDDٱAAADDADADDDDDDD4DDD4DDD4DDD4DDD4DDD4QfUdUT1A4AD4ADD4ffffefffefffTfffAdffefdfUeffVefffUfffUfffVefffUfffUefVffffffffffffffffffffffffefffUfffDDDD4DDDDDDDDDDDDDDDDDDDC4DDDDDD413CDDDDDDDDDDDADCDDDDDDDAD1431DDADDDADDDADDDADDDADDD1DDDCDD1DDAAD1D4D4D4D4D41D4CD4DDD4DADCDDDD4DADADDDDDDDDDDDDDDDDDDDDDDDDDD4DDD4DDD4DDD4DDD4DDD44ADDDADDDADDDADDDADDDDDDDDDDDDDDDDDDDDDDDADDDADDDADDADDADDADDADDADDDADDD4311DDDD4DDDDDDDDDDDDDDDDDDDDDDDDDDD14DDDD4DDD4DDD4ADDDADDDCDAAA1DADDDDDDDDDDDDD4DDD4DDD4DDD4!|d JA(NI1=sN        + + +  +  +  +        + + + +  +  + +       +!!"#$$#"%&'((')*+,-./00/.-1 + 23456789:;<=>>=<;:?@ABCDEFFEDCGHIJKLMNOPQRSSTUVWXYZZYXW[\]^_`abbbcdefghijklm +nopqqpo +  +r + st        bbuvwxyz{|}~bbb + +  +  +b +b    bbdb + + + +  +  +  \ No newline at end of file diff --git a/src/main/resources/dlc/IRAO/CGEAR2/C035_BK_1_e.bin b/src/main/resources/dlc/IRAO/CGEAR2/C035_BK_1_e.bin new file mode 100644 index 0000000..b178759 Binary files /dev/null and b/src/main/resources/dlc/IRAO/CGEAR2/C035_BK_1_e.bin differ diff --git a/src/main/resources/dlc/IRAO/CGEAR2/C036_WK_1_e.bin b/src/main/resources/dlc/IRAO/CGEAR2/C036_WK_1_e.bin new file mode 100644 index 0000000..805f92f Binary files /dev/null and b/src/main/resources/dlc/IRAO/CGEAR2/C036_WK_1_e.bin differ diff --git a/src/main/resources/dlc/IRAO/CGEAR2/C100-2_default_1_e.bin b/src/main/resources/dlc/IRAO/CGEAR2/C100-2_default_1_e.bin new file mode 100644 index 0000000..686665e Binary files /dev/null and b/src/main/resources/dlc/IRAO/CGEAR2/C100-2_default_1_e.bin differ diff --git a/src/main/resources/dlc/IRAO/MUSICAL/M010_munna_1_e_02.bin b/src/main/resources/dlc/IRAO/MUSICAL/M010_munna_1_e_02.bin new file mode 100644 index 0000000..6bc50b3 Binary files /dev/null and b/src/main/resources/dlc/IRAO/MUSICAL/M010_munna_1_e_02.bin differ diff --git a/src/main/resources/dlc/IRAO/MUSICAL/M013_meloetta_1_e.bin b/src/main/resources/dlc/IRAO/MUSICAL/M013_meloetta_1_e.bin new file mode 100644 index 0000000..d48b0a6 Binary files /dev/null and b/src/main/resources/dlc/IRAO/MUSICAL/M013_meloetta_1_e.bin differ diff --git a/src/main/resources/dlc/IRAO/ZUKAN/Z003_BWsyokigirl_1_en.bin b/src/main/resources/dlc/IRAO/ZUKAN/Z003_BWsyokigirl_1_en.bin new file mode 100644 index 0000000..8f59660 Binary files /dev/null and b/src/main/resources/dlc/IRAO/ZUKAN/Z003_BWsyokigirl_1_en.bin differ diff --git a/src/main/resources/dlc/IRAO/ZUKAN/Z004_BWsyokiboy_1_en.bin b/src/main/resources/dlc/IRAO/ZUKAN/Z004_BWsyokiboy_1_en.bin new file mode 100644 index 0000000..7803995 Binary files /dev/null and b/src/main/resources/dlc/IRAO/ZUKAN/Z004_BWsyokiboy_1_en.bin differ diff --git a/src/main/resources/dlc/IRAO/ZUKAN/Z007_hyuu_1_en.bin b/src/main/resources/dlc/IRAO/ZUKAN/Z007_hyuu_1_en.bin new file mode 100644 index 0000000..92b35d4 Binary files /dev/null and b/src/main/resources/dlc/IRAO/ZUKAN/Z007_hyuu_1_en.bin differ diff --git a/src/main/resources/dlc/IRAO/ZUKAN/Z008_bell_1_e.bin b/src/main/resources/dlc/IRAO/ZUKAN/Z008_bell_1_e.bin new file mode 100644 index 0000000..6de751b --- /dev/null +++ b/src/main/resources/dlc/IRAO/ZUKAN/Z008_bell_1_e.bin @@ -0,0 +1 @@ +"""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""2""""""""""2""2"B2""""CDD3wwww""2"2Oݪߪݬ̪̪̪ͪݪ着O#""O#"4""""""""""""O#""O#"O#O""""""""""""""""""""""""#"""O#""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""2""B"""""2"B2wwwywwxwwwwwwwwywwwxwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwϪϪߗwߪ̪̪̪̪̪̺̫̫̼̪̫̬̼̪̼̼ڪܪO#"O#?""""""""""""4"""4""$"#?"""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""2"""B""2"""2yxwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwߪϪw̫̫̼̬̫̪̺̪̪̪̪̪̫̬̼̼̪̪̪̪̪̪ܺ$"""#""O""?"#O""""""""""""""""""""""""?"""#"""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""B"""""B"""""B2ߪwwywywxwxwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww̪̫̬̼̫̫̪̪̪̪̪̺̬̫̼̼̪̫̬̬̪̪̪̪̪̪̼̫̫̺̪̪?""$"#?""""""""""""""""$"""O"""$""O"""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""B"""""B"""B"B߫ﭪߪwwwwwwywwywwxwwxwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww̗̼̫̫̺̪̪̬̬̫̪̼̫̼̺̪̫̪̼̬̼ʪ$"O"$O""""""""""""""""$"""O"""$""O"""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""2"""""""2""Bwwwwwwwwwwxwywwwwwwwwwwwwwwwwwwwwwxwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwߪ̺̪̪̪̪̫ˬ̼̬̼̫̬˪̪̪̺̼̪̼̫̼̪̺̼̪̪̪ʪ#"?""$?""""""""""""""""""""#"""?""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""B"""2""2ywwYwwwwwwWUywwxwwwwwywwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwxwwxwwwwwwwwwwwwwwwwwwwwwwwwwxwwwwwwwwwywwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww߇ϗߪϪ̪̪̪̪̪̪̪̫̬̼̼̪̪̪̼̬̼̬̼̪̼̪̪̪̼̪̼̪̫̬̬̫̼ڪ$""?""#"/"O"#/O"""""""""""""""""""""""""""""""""""2""""""""C"CCxwwwwwwxwwwwwwwwwwwwxwwwwwwwwwwwwwwwwwwwxywwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwywwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwywwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwͪ߬ͪߪͪ着#"""?""""""$""?""O""#"$"yywywwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwywwwwxwwxwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwݬߪߪݬݬݬ̼̺̪̫̪̼̬̼着?""$?Owwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwߪߪߪ̺̪̪̪̪̫ˬ̼̬̼̫̬ʪwwwwwwwwwwwwwwwwwwwwxwwwywwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwߪߪߪ̪̪̪̪̪̪̪̫̬̼̼̪̪̪̼̬̼着着wwwwwwxwwywwwwwwywwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwywwywwxwwxwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwߪߪߪ̪̬˪̪̪̺̼̪̼̫̼̪̺̼̪̪̪̼̺̪̫̪̼̬̼ڪڪʪʪwywwwwwwwwwwwwwwwwwwxwwwwwwxwwwwxwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwywwwywwwxwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwߪʪݪ߬߬߬Ϫ̬̼̪̼̪̪̪̼̪̼̪̫̬̬̫̼̺̪̪̪̪̫ˬ̼̬̼̫̬wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwywwUUUUwwwwwwwwwwwwwwwywwxwywwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwxwwwwYeUUUUwwwwwVUvWUUUVUUUUUUUUUUUUUUUUUUUwwwwwwwwwwwwvwwwuwwweUewUUUuUUUewwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwywxwxwwxwwwwwwwwwwwxwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwϪϪϪ̪̪̪̪̪̪̪̫̬̼̼̪̪̪̼̬̼wwwwwwwwwwwwwwwwwwwwwVUugUuwwWuwVUeUUUUUUVUUgUewgUUUUUUUuVUUvgUewxwwwwwwwwwwwwxwwwxwwwxwwww_UUXUUUUUhUUUwUUUwgUUwwVUwwUUUvwVuwwwvwwwwwwwwwwwwwwwvwwwuwwwUUUUUUUUVUUUWUUeWUUuWUewVUvwUUuwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwywxwwwxwwwwwxwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwϪϪߪܪͪͪͪwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwgUUwWUUwWUUwWUUwgUUwwUUwwgUwwwwUvwVUUUUUUUUUUUUUUVUUuwUewwgwwwwUUewUUUwUUUwUUUwUUewUUuwUewwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwywxwwwwwwwywwxwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwϪߪϪwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww̪̪̪̫̫̼̪̫̬̼̪̼̼̽wwgewgUewVUewUUegUUeWUUeVUUeUUUevwwUvwUewUUwUUvUUuUUeUUUwwwwwwwwwwwwwwwwwwwwwwwwUewwUUewwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww̫̫̼̬̫̪̺̪̪̪̪̪̫̬̼̼UUUeUUUeUUUvUUvwUewwUuwwUvwwUwwwUUUUUUUUUgUUwVUwWUwgUwwUUUUvUUUuUUUeUUUUUUUUUUUUUUUUUUUewwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwxwwywwwwwwwwxwywwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww̪̫̬̼̫̫̪̪̪̪̪̺̬̫̼̼̪̫̬̬UwwwUvwwUuwwUewUUUUUUwwUwgUwWUwVUgUUgUUUUUUUUUUUUUUuUUUvUUewUewwUUwwUUvwUUuwUUuwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwywwwwwwwwwwwwxwwwwwwxwwwwxwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwߺߪ̡̼̫̫̺̪̪̬̬̫̪̼̫̪̪̪̪̪̺̫̫̼̬̼ͪͪͪUUUUUUUUUUUUUUUUUUeUUUvUUvwwwwwUUUUUUUUUUUUUUUUVUUUgUUUwgUUwwwwUUuwUUuwUUvwUUwwUewwUvwwvwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwgUewUUUgUUUwwwwwVUvWUUUVUUUUUUUUUUUUUUUUUUUwwwwwwwwwwwwvwwwuwwweUewUUUuUUUewwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwywwwwwwwwxwwwxwwxwwwwwwwwwwwwwwwwwwwwwwwwwxwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwߪߪߪ̫̫̼̬̫̪̺̪̪̪̪̪̬̼̼wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwWUUUWUUUWUUUgUUUwUUUwgUUwwVUwwUUUvwVuwwwvwwwwwwwwwwwwwwwvwwwuwwwUUUUUUUUVUUUWUUeWUUuWUewVUvwUUuwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwxxwxwwwxxwwwwwwwwwwwwwߪߪߪ̺̪̪̪̪̪̪̪̫̬̼̫̫̪̪̪̪̪̺̬̫̼̼̪̫̬̬wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwgUUwWUUwWUUwWUUwgUUwwUUwwgUwwwwUvwVUUUUUUUUUUUUUUVUUuwUewwgwwwwUUewUUUwUUUwUUUwUUewUUuwUewwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwVUwVUUhUwgUUwVUUwUUUgUUUWUUUVUUUUUUUUvwwUUvwUUewUUUwUUUvUUUuUUUeUUUUwwwwwwwwwwwwwwwwwwwwwwwwUewwUUewwwwwwwwwwwwwwwwwwwwwߪߪߪ̪̪̪̪̪̪̼̫̫̺̪̪̬̬̫̪̼̫wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwVwwgUwwWUwfWUgUUUVUUeUUUwUUewUvwwUewwUUwwUUgvUUUegUUUwWUUwgUUwwwwwwwwwwwwwwwwwwwwvwwwuwwwuwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwgUUUWUUUVUUUUUUUUUUUUUUUUUUUVUUUUUUUUUUUUUUvUUvwUewwUuwwUvwwUwwwUUUUUUUUgUUUwgUUwwVUwwWUwwgUwwwUUUUvUUUuUUUeUUUUUUUUUUUUUUUUUUUewwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwvߪߪߪ*P "w \[goN1 Bws{oZk:g9gc^ZVRNsNRJ1FB=951k-J))))%)!!||||||c B|!J)||||||||P j-RJ9gw| w"|||p \ No newline at end of file diff --git a/src/main/resources/dlc/IRAO/ZUKAN/Z009_tyeren_1_e.bin b/src/main/resources/dlc/IRAO/ZUKAN/Z009_tyeren_1_e.bin new file mode 100644 index 0000000..a05f320 --- /dev/null +++ b/src/main/resources/dlc/IRAO/ZUKAN/Z009_tyeren_1_e.bin @@ -0,0 +1 @@ +33333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333CO333O334333333333333O333O33O3O3333333333333333333333333333O33333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333C33C3C3333ݽͻݼͼ̻ݻݻݻݻ˻̻ݻ̻O33O3?3333333333334333433433?33333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333C3333333ܽͻܽݽݼ̻̻ݻ̻̻ͻۻ4333333O33?33O333333333333333333333333?333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333C3ݻܼܽͻ̻ܽܽͻۻ̻̻̻ͻ?33433?33333333333333333333?333333?33333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333ܼܻܻ̻̻̻̽̽ͻܻ33?33?33333333333333333333?333333?333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333C߻߻߻̻̻̻ͻۻ̻33?334?333333333333333333333333?333333333333333333333333333333333333333333333333333333333333333333333333C3333333_efofef_ff_eVffffVffffffVfffVfffuW߻߻߻̻̻̻ͻۻ˻433?3333?3O33?O33333333333333333333333333333333333333333333C3333333C_efofffff_fffofffffffffffffffffffffVwffewffuwfVwwfuwwVwwwewwwuwwwwwWwwwwWwwwwwwWwwwwwwwwW߻߻߻̻̻̻̻̻ͻܻ3333?333333433?33O33334333Cofefff_ffofffff_fffoffffffVfffefffuffVwffewffuwfVwwfewwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwWwwwwWwwwwwwWwwwwwwwwW߻߻߻̻̻̻̻̻?334?O_oef_fofeffffffffffffffffffffffVfffVfffufuwwVwwwVwwwuwwwuwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwWwwwwWwwwwwwWwwwwwwwwW߻߻߻̻ͻͻۻۻefefff_ffoffefffffffffffuffVwffVwffuwffuwfVvwfVwwfVwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwWwwwwWwwwwwwWwwwwwwwwW߻߻߻̼̼̻̻̻̻̻̻̻_fff_fffofffefffeffffffffffffffffuwwfuwwVvwwVwwwVwwwewwwuwwwuwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwWwwwwWwwwwwwWwwwwwwwwW߻̼̼̼̻̻̻ͻͻͻۻۻfffVfffVfffVfffefffufffufffuffVvvwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwWwwwwWwwwwwwWwwwwwwwwW̻̼̻̼̻̼ffVwffVwffewffewffuwffUvfffUmfffwwwwwwwwwwwwwwwwwwwwwwwwvwwwUvwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww'www"rww""w'""w"""w"""w"""'"""wwWwwrwRrwfrgfrgfwf_u_we_vw_vwwvwwwwwwwwwww_evwwwwwwwwwwwwwwwwwwwwwwwwwwgVwwwwwwwwwwwwwwwwwwwwwwwwVwgwwgwwwgwwwwwwwwﯙWw̻̼̻̱̼̼̻˻̻̻̻̻fffͻܻfUvwffUwffVwmfVwfVwfVwfVwfVwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww'www'www'www"'""""""r"""r"""w"""w""rg""rg""rgwfgfgffffff_u_wvwowwvwwowwwvwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwgwwwwgwwwwwwgwwwwwwwwwwwwﯙWﯙw̻̼̼̻̻̻̼̼̻̼̱̻̻̻ܻͻͻͻfVwfVwfVwfVwfVwfVwfVwfVwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww"www"www"ww'"ww'"ww'"ww'"ww'"""wf""wf""'f""gf"rgf"rgf"rgf"rbVVowuwvw_wwowwwwuwwvwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwWwgwwwwwwwwWwwg琢̻UfwwUVw̼̻̱̻̼̻fVwfVwfVwfVwfVwfVwfVwfVwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww""ww""ww""ww""ww""ww""ww""ww"""rbV"wf"wf"wf"wf"wf"wf"wfwww_www_wwwowwwowwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww_UVwfVwfVw_eevwwwwwwwwwwwwww_ewvwwwwwwwwwwwwwwwwwwwUfwwwwwwwwwwwwwwwwwwwwwwwww_evwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwgwwwwwgwwwwwW̼̻̼̻̱fVwfVwfVwfVwfVwfVwfVwfVwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww""ww""ww""ww""ww""ww""ww""ww"""wf"wf"wf"wf"wf"wf"wf"rbVw_fwWfwwwwwwwwwowwwowww_www_wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwfVwfVwfVwfVwfVwfVwfew_fewwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwWwwwwwgwgwg̼̱̼̻̻fVwfVwfVwfVwfVwfVwfVwfVwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww'"ww'"ww'"ww'"ww'"www"www"www""rbV"rgf"rgf"rgf""gf""'f""wf""wfvvuuoVwWfwWfwWeVwwefwwefwwefwwVfwwWevwwuwwwwoww_wwvwuwVowwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwgwwWwwwwwwwg_wWoweofuwofuweVuwfVwwfVwwfVwwfewwVuwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwgwwwwwwgwwg̼̻̼̼̻ܻffffVwfVwfVwfVwfVwffVwffUwfUvwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww"www'www'www'wwwwwwwwwwwwwwww""rg""rg""rg"""w"""w"""r"""r'"""fffffgfgfwfuwwewweowwVwwWvwwww_wwwwf_wfuf_efefVfVWefwefwwwwwwwwwwwwvwwwowwwvwwowwvwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwgwwwwwgwwwgwfW_foffV_fVefefVuofVwVwwwVwwwewwwuwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwWwwwgwg̱̱̼̻ܻ̻̻̻ͻͻͻffffffUffUvfVvwfVwwfVwwfVwwfVwwUvwwvwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww'"""w"""w"""w"""w'""ww""ww""ww'"wfwgfrgf"wf"wff"rgf""wf""wbw_wvoVfwVffwgefwwVfwwgevwwVowwgvwwoww_wuf_fVffUffWeffwVefwwwwwwwwvwww_vww_vwefwwwwwwwwwwwwwwwwwwwwwwww_evwwwwwwwwwwwwwwwwwwwwwwwwwwgVwwwwwwwwwwwgwwgwgV__fwWo_fefoffoffUffVufVewffewfVvwfewwVvwwewwwvwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwgwwwwwgwwwwwWwg̻̼̻̼̱ݻ˻̻̻̻̻̻fVwwfVwwfVwwfVwwfVwwfVwwfVwwfVwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww"www"www'wwwwwwwwwwwwwwwwwwww""rg"""w"""w"""r""""'"""w"""w'""fffgfwffwgfrwff"wgf"rwfvwowuVfwwUfwwgUwwwwwwww_wwwowwow_ffVefffUeffgUUfwwWUwwwwwwwwvwwwVffffffffUffffUUUwwwwwwwwefffffffffffUUUUfwwwwwwwwefffffVffVUfUUvUuwwwwwwwwwwwwwwfUwwUvwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwgwwwwwwwwgwwwgw̼̻̼̱̼̻̻̻̻̻̻̻̻fVwwfVwwfVwwfVwwfVwwfVwwfVwwfVwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww""ww'"www"www'wwwwwwwwwwwwwwww""wg""rw"""r""""""""'"""w"""w'""ffbfVwffwgff"wbf"rwg""rw"""wfffVgfffwgffeww_efVwwwwvwwwUfwoo__wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwgwwwwwgwwwwwW߻߻߻̻̻̻̻̻̻̻fVwwfVwwfVwwfVwwfVwwfVwwfVwwfVwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww'"www"wwwwwwwwwwwwwwwwwwwwwwww""""""""""""'"""w'""ww""www'wwwwrwgf"rw'""rw""""""""""""""""'"""ffffffffwbffwwwf""wf""wf""wf""wfVffwwwwvwwwvwwwuwwwuwwwwwwwwwowwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwWwwwwwgwgwg߻߻߻̻̻̻̻̻̻̻̣*R|kI-9_s< BZI-Bws{oZk:g9gc^ZVRNsNRJ1FB=951k-J))))%)!!||||||cB|!J)||||||||fBZIb=-j-RJ9gw|(%I-||| \ No newline at end of file diff --git a/src/main/resources/dlc/IRAO/ZUKAN/Z100_defogirl_1_en.bin b/src/main/resources/dlc/IRAO/ZUKAN/Z100_defogirl_1_en.bin new file mode 100644 index 0000000..2d1bec5 Binary files /dev/null and b/src/main/resources/dlc/IRAO/ZUKAN/Z100_defogirl_1_en.bin differ diff --git a/src/main/resources/dlc/IRAO/ZUKAN/Z101_defoboy_1_en.bin b/src/main/resources/dlc/IRAO/ZUKAN/Z101_defoboy_1_en.bin new file mode 100644 index 0000000..e528232 Binary files /dev/null and b/src/main/resources/dlc/IRAO/ZUKAN/Z101_defoboy_1_en.bin differ diff --git a/src/main/resources/log4j2.xml b/src/main/resources/log4j2.xml new file mode 100644 index 0000000..ab8ef3a --- /dev/null +++ b/src/main/resources/log4j2.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/test/java/entralinked/utility/Crc16Test.java b/src/test/java/entralinked/utility/Crc16Test.java new file mode 100644 index 0000000..c41ebfb --- /dev/null +++ b/src/test/java/entralinked/utility/Crc16Test.java @@ -0,0 +1,32 @@ +package entralinked.utility; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +public class Crc16Test { + + @Test + @DisplayName("Test if output CRC-16 checksums are valid") + void testCrc16Checksums() { + // Test checksums calculated from byte arrays with varying lengths + assertEquals(0xEF9D, Crc16.calc(new byte[] {114, 49, -30, -50, 46, -62, 47, 39, -85, 73, -91, 40, 21, -80, -95, -3})); + assertEquals(0x23DF, Crc16.calc(new byte[] {-32, -95, 74, 56, 2, -57, 90, 78, 81, 81, -126, 29, 8, 1, 65, -7})); + assertEquals(0x263D, Crc16.calc(new byte[] {-128, 47, -44, 118, 1, 91, 124, 104, 2, -4, -84, -76})); + assertEquals(0xBF19, Crc16.calc(new byte[] {-9, 108, -105, -33, -110, -8, 33, 44})); + + // Test checksums calculated from different sections of a byte array + byte[] bytes = {81, -81, -69, -18, 70, -94, -61, 73, -63, 56, 56, 113, -75, -87, -30, -31, -76, 76, -120, -14, -79, -43, -117, -22, 23, 9, -81, 77, 64, -93, 48, 1}; + assertEquals(0xC8F5, Crc16.calc(bytes, 0, 4)); // bytes 0-3 + assertEquals(0x6093, Crc16.calc(bytes, 4, 8)); // bytes 4-11 + assertEquals(0xD7C3, Crc16.calc(bytes, 8, 8)); // bytes 8-15 + assertEquals(0xFF5C, Crc16.calc(bytes, 16, 16)); // bytes 16-31 + + // Test checksums calculated from integers + assertEquals(0x9EFB, Crc16.calc(12345)); + assertEquals(0x005E, Crc16.calc(847190349)); + assertEquals(0x8C87, Crc16.calc(Integer.MAX_VALUE)); + assertEquals(0x1548, Crc16.calc(Integer.MIN_VALUE)); + } +} diff --git a/src/test/java/entralinked/utility/GsidUtilityTest.java b/src/test/java/entralinked/utility/GsidUtilityTest.java new file mode 100644 index 0000000..7aaa063 --- /dev/null +++ b/src/test/java/entralinked/utility/GsidUtilityTest.java @@ -0,0 +1,45 @@ +package entralinked.utility; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +public class GsidUtilityTest { + + @Test + @DisplayName("Test if output stringified Game Sync IDs are correct") + void testGameSyncIdStringifier() { + assertEquals("G5T5MB69TA", GsidUtility.stringifyGameSyncId(45991782)); + assertEquals("S6MJNM63AC", GsidUtility.stringifyGameSyncId(381955984)); + assertEquals("RMLLERWPSA", GsidUtility.stringifyGameSyncId(507849071)); + assertEquals("J89BGT23UD", GsidUtility.stringifyGameSyncId(576782280)); + assertEquals("K3D29LTGSB", GsidUtility.stringifyGameSyncId(1442582313)); + assertEquals("8YJN6SKKGF", GsidUtility.stringifyGameSyncId(1640375006)); + } + + @Test + @DisplayName("Test if invalid Game Sync IDs are seen as invalid") + void testInvalidGameSyncIds() { + // Illegal characters (I, O, 0, 1) + assertFalse(GsidUtility.isValidGameSyncId("0000000000")); + assertFalse(GsidUtility.isValidGameSyncId("ABCDEFGHIJ")); + assertFalse(GsidUtility.isValidGameSyncId("1OEKLRO493")); + + // Illegal length (should be 10) + assertFalse(GsidUtility.isValidGameSyncId("Y67UEN38K")); + assertFalse(GsidUtility.isValidGameSyncId("3ER5K8MBN4C")); + } + + @Test + @DisplayName("Test if valid Game Sync IDs are seen as valid") + void testValidGameSyncIds() { + assertTrue(GsidUtility.isValidGameSyncId("VFWM2QAXNF")); + assertTrue(GsidUtility.isValidGameSyncId("44DAWDJKJ8")); + assertTrue(GsidUtility.isValidGameSyncId("J6F55UB2X9")); + assertTrue(GsidUtility.isValidGameSyncId("8FAB4Z3EN9")); + assertTrue(GsidUtility.isValidGameSyncId("HWLNS7BTNB")); + } +} diff --git a/src/test/java/entralinked/utility/MD5Test.java b/src/test/java/entralinked/utility/MD5Test.java new file mode 100644 index 0000000..af32c8b --- /dev/null +++ b/src/test/java/entralinked/utility/MD5Test.java @@ -0,0 +1,17 @@ +package entralinked.utility; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +public class MD5Test { + + @Test + @DisplayName("Test if output MD5 hashes are correct") + void testMD5Hashes() { + assertEquals("ed076287532e86365e841e92bfc50d8c", MD5.digest("Hello World!")); + assertEquals("8cfd799409ac5461004bca394a92b0af", MD5.digest("Some random string.")); + assertEquals("c74efaf9dd2782003ba4b27f15ef1049", MD5.digest("What is the meaning of life?")); + } +}