This commit is contained in:
Disturbo 2025-07-30 21:33:00 +00:00 committed by GitHub
commit 4e03f31e6d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 353 additions and 44 deletions

3
.gitignore vendored
View File

@ -10,3 +10,6 @@ testRun
.project
.classpath
bin
# IntelliJ
.idea

View File

@ -66,8 +66,9 @@ public class PkmnInfoReader {
int form = (buffer.getByte(64) >> 3) & 0x1F;
boolean genderless = ((buffer.getByte(64) >> 2) & 1) == 1;
boolean female = ((buffer.getByte(64) >> 1) & 1) == 1;
int natureByte = buffer.getByte(65);
PkmnGender gender = genderless ? PkmnGender.GENDERLESS : female ? PkmnGender.FEMALE : PkmnGender.MALE;
PkmnNature nature = PkmnNature.valueOf(buffer.getByte(65));
PkmnNature nature = PkmnNature.valueOf(natureByte);
String nickname = getString(buffer, 72, 20);
String trainerName = getString(buffer, 104, 14);
@ -75,13 +76,13 @@ public class PkmnInfoReader {
if(!buffer.release()) {
logger.warn("Buffer was not deallocated!");
}
// Loosely verify data
if(species < 1 || species > 649) throw new IOException("Invalid species");
if(heldItem < 0 || heldItem > 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");
if(species < 1 || species > 649) throw new IOException(String.format("Invalid species: %d", species));
if(heldItem < 0 || heldItem > 638) throw new IOException(String.format("Invalid held item: %d", heldItem));
if(ability < 1 || ability > 164) throw new IOException(String.format("Invalid ability: %d", ability));
if(level < 1 || level > 100) throw new IOException(String.format("Level is out of range: %d", level));
if(nature == null) throw new IOException(String.format("Invalid nature: %d", natureByte));
// Create record
return new PkmnInfo(nickname, trainerName, nature, gender, species, personality,

View File

@ -0,0 +1,18 @@
package entralinked.model.player;
public enum GymBadge {
BADGE_ONE(0b00000001),
BADGE_TWO(0b00000010),
BADGE_THREE(0b00000100),
BADGE_FOUR(0b00001000),
BADGE_FIVE(0b00010000),
BADGE_SIX(0b00100000),
BADGE_SEVEN(0b01000000),
BADGE_EIGHT(0b10000000);
public final int mask;
GymBadge(int mask) {
this.mask = mask;
}
}

View File

@ -0,0 +1,29 @@
package entralinked.model.player;
import entralinked.GameVersion;
public class Offsets {
public static final int TRAINER_INFO = 0x19400;
public static final int TRAINER_INFO_SIZE = 0x67;
public static final int TRAINER_NAME_SUB_OFFSET = 0x4;
public static final byte TRAINER_NAME_SIZE = 0x10;
public static final int TRAINER_ID_SUB_OFFSET = 0x14;
public static final int SECRET_ID_SUB_OFFSET = 0x16;
public static final int COUNTRY_SUB_OFFSET = 0x1C;
public static final int REGION_SUB_OFFSET = 0x1D;
public static final int GENDER_OFFSET = 0x21;
public static final int PLAYTIME_OFFSET = 0x24;
public static final int DREAM_WORLD_INFO = 0x1D300;
public static final int POKEMON_INFO_SUB_OFFSET = 0x8;
public static final int ADVENTURE_START_TIME_OFFSET = 0x1D900;
public static final int ADVENTURE_START_TIME_SUB_OFFSET = 0x34;
public static final int MONEY_AND_BADGES_VERSION_1 = 0x21200;
public static final int MONEY_AND_BADGES_VERSION_2 = 0x21100;
public static int getMoneyAndBadges(GameVersion gameVersion) {
return gameVersion.isVersion2() ? MONEY_AND_BADGES_VERSION_2 : MONEY_AND_BADGES_VERSION_1;
}
}

View File

@ -19,6 +19,7 @@ public class Player {
private final List<DreamDecor> decor = new ArrayList<>();
private PlayerStatus status;
private GameVersion gameVersion;
private TrainerInfo trainerInfo;
private PkmnInfo dreamerInfo;
private int levelsGained;
private String cgearSkin;
@ -52,10 +53,7 @@ public class Player {
}
public void setEncounters(Collection<DreamEncounter> encounters) {
if(encounters.size() <= 10) {
this.encounters.clear();
this.encounters.addAll(encounters);
}
setList(encounters, this.encounters, 10);
}
public List<DreamEncounter> getEncounters() {
@ -63,10 +61,7 @@ public class Player {
}
public void setItems(Collection<DreamItem> items) {
if(encounters.size() <= 20) {
this.items.clear();
this.items.addAll(items);
}
setList(items, this.items, 20);
}
public List<DreamItem> getItems() {
@ -74,10 +69,7 @@ public class Player {
}
public void setAvenueVisitors(Collection<AvenueVisitor> avenueVisitors) {
if(avenueVisitors.size() <= 12) {
this.avenueVisitors.clear();
this.avenueVisitors.addAll(avenueVisitors);
}
setList(avenueVisitors, this.avenueVisitors, 12);
}
public List<AvenueVisitor> getAvenueVisitors() {
@ -85,16 +77,13 @@ public class Player {
}
public void setDecor(Collection<DreamDecor> decor) {
if(decor.size() <= 5) {
this.decor.clear();
this.decor.addAll(decor);
}
setList(decor, this.decor, 5);
}
public List<DreamDecor> getDecor() {
return Collections.unmodifiableList(decor);
}
public void setStatus(PlayerStatus status) {
this.status = status;
}
@ -102,7 +91,15 @@ public class Player {
public PlayerStatus getStatus() {
return status;
}
public void setTrainerInfo(TrainerInfo trainerInfo) {
this.trainerInfo = trainerInfo;
}
public TrainerInfo getTrainerInfo() {
return trainerInfo;
}
public void setGameVersion(GameVersion gameVersion) {
this.gameVersion = gameVersion;
}
@ -170,11 +167,11 @@ public class Player {
public void setCustomMusical(String customMusical) {
this.customMusical = customMusical;
}
public String getCustomMusical() {
return customMusical;
}
// IO stuff
public void setDataDirectory(File dataDirectory) {
@ -200,8 +197,15 @@ public class Player {
public File getDexSkinFile() {
return new File(dataDirectory, "zukan.bin");
}
public File getMusicalFile() {
return new File(dataDirectory, "musical.bin");
}
private static <T> void setList(Collection<T> source, List<T> target, int maxSize) {
if(source.size() <= maxSize) {
target.clear();
target.addAll(source);
}
}
}

View File

@ -17,6 +17,7 @@ public record PlayerDto(
@JsonProperty(required = true) String gameSyncId,
@JsonProperty(required = true) GameVersion gameVersion,
PlayerStatus status,
TrainerInfo trainerInfo,
PkmnInfo dreamerInfo,
String cgearSkin,
String dexSkin,
@ -31,10 +32,11 @@ public record PlayerDto(
@JsonDeserialize(contentAs = DreamDecor.class) Collection<DreamDecor> decor) {
public PlayerDto(Player player) {
this(player.getGameSyncId(), player.getGameVersion(), player.getStatus(), player.getDreamerInfo(),
player.getCGearSkin(), player.getDexSkin(), player.getMusical(), player.getCustomCGearSkin(),
player.getCustomDexSkin(), player.getCustomMusical(), player.getLevelsGained(), player.getEncounters(),
player.getItems(), player.getAvenueVisitors(), player.getDecor());
this(player.getGameSyncId(), player.getGameVersion(), player.getStatus(), player.getTrainerInfo(),
player.getDreamerInfo(), player.getCGearSkin(), player.getDexSkin(), player.getMusical(),
player.getCustomCGearSkin(), player.getCustomDexSkin(), player.getCustomMusical(),
player.getLevelsGained(), player.getEncounters(), player.getItems(), player.getAvenueVisitors(),
player.getDecor());
}
/**
@ -44,6 +46,7 @@ public record PlayerDto(
Player player = new Player(gameSyncId);
player.setStatus(status);
player.setGameVersion(gameVersion);
player.setTrainerInfo(trainerInfo);
player.setDreamerInfo(dreamerInfo);
player.setCGearSkin(cgearSkin);
player.setDexSkin(dexSkin);

View File

@ -0,0 +1,9 @@
package entralinked.model.player;
import com.fasterxml.jackson.annotation.JsonProperty;
public record Playtime (
@JsonProperty(required = true) int hours,
@JsonProperty(required = true) int minutes,
@JsonProperty(required = true) int seconds
) {}

View File

@ -0,0 +1,17 @@
package entralinked.model.player;
import com.fasterxml.jackson.annotation.JsonEnumDefaultValue;
public enum TrainerGender {
@JsonEnumDefaultValue
MALE,
FEMALE;
public static TrainerGender valueOf(int gender) {
return switch (gender) {
case 0 -> MALE;
case 1 -> FEMALE;
default -> MALE;
};
}
}

View File

@ -0,0 +1,58 @@
package entralinked.model.player;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
public class TrainerInfo {
@JsonProperty(required = false) private String trainerName;
@JsonProperty(required = false) private int trainerId;
@JsonProperty(required = false) private int secretId;
@JsonProperty(required = false) private int country;
@JsonProperty(required = false) private int region;
@JsonProperty(required = false) private TrainerGender gender;
@JsonProperty(required = false) private Playtime playtime;
@JsonProperty(required = false) private long adventureStartTime;
@JsonProperty(required = false) private long money;
@JsonProperty(required = false)
@JsonDeserialize(contentAs = GymBadge.class)
private final List<GymBadge> gymBadges = new ArrayList<>();
public TrainerInfo() {}
@JsonIgnore
public TrainerInfo(
String trainerName, int trainerId, int secretId, int country, int region, TrainerGender gender, Playtime playtime
) {
this.trainerName = trainerName;
this.trainerId = trainerId;
this.secretId = secretId;
this.country = country;
this.region = region;
this.gender = gender;
this.playtime = playtime;
}
@JsonIgnore
public void setAdventureStartTime(long adventureStartTime) {
this.adventureStartTime = adventureStartTime;
}
@JsonIgnore
public void setMoney(long money) {
this.money = money;
}
@JsonIgnore
public void setGymBadges(Collection<GymBadge> gymBadges) {
if(gymBadges.size() <= 8) {
this.gymBadges.clear();
this.gymBadges.addAll(gymBadges);
}
}
}

View File

@ -0,0 +1,103 @@
package entralinked.model.player;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufAllocator;
import io.netty.buffer.PooledByteBufAllocator;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
public class TrainerInfoReader {
private static final int BASE_ADVENTURE_START_TIME = 946684800; // Dates are based on 2000/01/01 00:00:00
private static final byte ZERO_BYTE = (byte) 0x00;
private static final byte FF_BYTE = (byte) 0xFF;
private static final Logger logger = LogManager.getLogger();
private static final ByteBufAllocator bufferAllocator = PooledByteBufAllocator.DEFAULT;
public static TrainerInfo readTrainerInfo(InputStream inputStream) throws IOException {
ByteBuf buffer = bufferAllocator.buffer(Offsets.TRAINER_INFO_SIZE);
buffer.writeBytes(inputStream, Offsets.TRAINER_INFO_SIZE);
String trainerName = readTrainerName(buffer);
int trainerId = buffer.getUnsignedShortLE(Offsets.TRAINER_ID_SUB_OFFSET);
int secretId = buffer.getUnsignedShortLE(Offsets.SECRET_ID_SUB_OFFSET);
int country = buffer.getUnsignedByte(Offsets.COUNTRY_SUB_OFFSET);
int region = buffer.getUnsignedByte(Offsets.REGION_SUB_OFFSET);
TrainerGender gender = TrainerGender.valueOf(buffer.getUnsignedByte(Offsets.GENDER_OFFSET));
Playtime playtime = readPlaytime(buffer);
// Try release buffer
if(!buffer.release()) {
logger.warn("Buffer was not deallocated!");
}
return new TrainerInfo(trainerName, trainerId, secretId, country, region, gender, playtime);
}
private static String readTrainerName(ByteBuf buffer) {
StringBuilder playerName = new StringBuilder();
for(int i = 0; i < Offsets.TRAINER_NAME_SIZE; i++) {
byte currByte = buffer.getByte(Offsets.TRAINER_NAME_SUB_OFFSET + i);
if (currByte == FF_BYTE) {
break;
} else if (currByte != ZERO_BYTE) {
playerName.append((char) currByte);
}
}
return playerName.toString();
}
private static Playtime readPlaytime(ByteBuf buffer) {
return new Playtime(
buffer.getUnsignedShortLE(Offsets.PLAYTIME_OFFSET),
buffer.getUnsignedByte(Offsets.PLAYTIME_OFFSET + 2),
buffer.getUnsignedByte(Offsets.PLAYTIME_OFFSET + 3)
);
}
public static long readAdventureStartTime(InputStream inputStream) throws IOException {
return readLong(inputStream) + BASE_ADVENTURE_START_TIME;
}
public static long readLong(InputStream inputStream) throws IOException {
ByteBuf buffer = bufferAllocator.buffer(4);
buffer.writeBytes(inputStream, 4);
long read = buffer.getUnsignedIntLE(0);
// Try release buffer
if(!buffer.release()) {
logger.warn("Buffer was not deallocated!");
}
return read;
}
public static List<GymBadge> readGymBadges(InputStream inputStream) throws IOException {
ByteBuf buffer = bufferAllocator.buffer(1);
buffer.writeBytes(inputStream, 1);
List<GymBadge> gymBadges = new ArrayList<>();
int badgeFlags = buffer.getUnsignedByte(0);
for(GymBadge badge : GymBadge.values()) {
if ((badgeFlags & badge.mask) == badge.mask) {
gymBadges.add(badge);
}
}
// Try release buffer
if(!buffer.release()) {
logger.warn("Buffer was not deallocated!");
}
return gymBadges;
}
}

View File

@ -28,6 +28,9 @@ import entralinked.model.player.DreamItem;
import entralinked.model.player.Player;
import entralinked.model.player.PlayerManager;
import entralinked.model.player.PlayerStatus;
import entralinked.model.player.TrainerInfo;
import entralinked.model.player.Offsets;
import entralinked.model.player.TrainerInfoReader;
import entralinked.model.user.ServiceSession;
import entralinked.model.user.User;
import entralinked.model.user.UserManager;
@ -36,6 +39,7 @@ import entralinked.network.http.HttpRequestHandler;
import entralinked.serialization.UrlEncodedFormFactory;
import entralinked.serialization.UrlEncodedFormParser;
import entralinked.utility.GsidUtility;
import entralinked.utility.PointedInputStream;
import entralinked.utility.LEOutputStream;
import io.javalin.Javalin;
import io.javalin.http.Context;
@ -276,7 +280,7 @@ public class PglHandler implements HttpHandler {
outputStream.writeShort(0x7E); // Just reset to default state
outputStream.writeBytes(0, 24);
}
outputStream.writeShort(0); // ?
// Join Avenue visitor data -- copied in parts to 0x2422C in the save file.
@ -321,13 +325,13 @@ public class PglHandler implements HttpHandler {
*/
private void handleMemoryLink(PglRequest request, Context ctx) throws IOException {
LEOutputStream outputStream = new LEOutputStream(ctx.outputStream());
// Check if Game Sync ID is valid
if(!GsidUtility.isValidGameSyncId(request.gameSyncId())) {
writeStatusCode(outputStream, 8); // Invalid Game Sync ID
return;
}
Player player = playerManager.getPlayer(request.gameSyncId());
User user = ctx.attribute("user");
@ -419,7 +423,7 @@ public class PglHandler implements HttpHandler {
private void handleUploadSaveData(PglRequest request, Context ctx) throws IOException {
LEOutputStream outputStream = new LEOutputStream(ctx.outputStream());
Player player = playerManager.getPlayer(request.gameSyncId());
// Check if the player exists, has no Pokémon tucked in already and uses the same game version
if(player == null
|| (!configuration.allowOverwritingPlayerDreamInfo() && player.getStatus() != PlayerStatus.AWAKE)
@ -446,17 +450,31 @@ public class PglHandler implements HttpHandler {
}
// Read save data
PkmnInfo dreamerInfo = null;
TrainerInfo trainerInfo;
PkmnInfo dreamerInfo;
try(FileInputStream inputStream = new FileInputStream(player.getSaveFile())) {
inputStream.skip(0x1D300); // Skip to dream world data
inputStream.skip(8); // Skip to Pokémon data
try(PointedInputStream inputStream = new PointedInputStream(new FileInputStream(player.getSaveFile()))) {
inputStream.skipTo(Offsets.TRAINER_INFO);
trainerInfo = TrainerInfoReader.readTrainerInfo(inputStream);
inputStream.skipTo(Offsets.DREAM_WORLD_INFO);
inputStream.skip(Offsets.POKEMON_INFO_SUB_OFFSET);
dreamerInfo = PkmnInfoReader.readPokeInfo(inputStream);
inputStream.skipTo(Offsets.ADVENTURE_START_TIME_OFFSET);
inputStream.skip(Offsets.ADVENTURE_START_TIME_SUB_OFFSET);
trainerInfo.setAdventureStartTime(TrainerInfoReader.readAdventureStartTime(inputStream));
inputStream.skipTo(Offsets.getMoneyAndBadges(request.gameVersion()));
trainerInfo.setMoney(TrainerInfoReader.readLong(inputStream));
trainerInfo.setGymBadges(TrainerInfoReader.readGymBadges(inputStream));
}
// Update and save player information
player.setStatus(PlayerStatus.SLEEPING);
player.setGameVersion(request.gameVersion());
player.setTrainerInfo(trainerInfo);
player.setDreamerInfo(dreamerInfo);
if(!playerManager.savePlayer(player)) {
@ -523,7 +541,7 @@ public class PglHandler implements HttpHandler {
writeStatusCode(outputStream, 8); // Invalid Game Sync ID
return;
}
// Check if player doesn't exist already
if(playerManager.doesPlayerExist(gameSyncId)) {
writeStatusCode(outputStream, 2); // Duplicate Game Sync ID
@ -548,7 +566,7 @@ public class PglHandler implements HttpHandler {
outputStream.writeInt(status);
outputStream.writeBytes(0, 124);
}
/**
* Gets the index of the player's chosen DLC for the specified type and prepares DLC overriding if necessary.
*/

View File

@ -0,0 +1,46 @@
package entralinked.utility;
import org.jetbrains.annotations.NotNull;
import java.io.FilterInputStream;
import java.io.IOException;
import java.io.InputStream;
public class PointedInputStream extends FilterInputStream {
private long pointer = 0;
public PointedInputStream(InputStream inputStream) {
super(inputStream);
}
@Override
public int read() throws IOException {
int read = super.read();
if (read != -1) pointer += 1;
return read;
}
@Override
public int read(@NotNull byte[] b, int off, int len) throws IOException {
int bytes = super.read(b, off, len);
pointer += bytes;
return bytes;
}
public long skipTo(long n) throws IOException {
return skip(n - pointer);
}
@Override
public long skip(long n) throws IOException {
long bytes = super.skip(n);
pointer += bytes;
return bytes;
}
@Override
public void reset() throws IOException {
pointer = 0;
super.reset();
}
}