2023-11-09 21:59:45 +00:00
|
|
|
import { createWriter, createReader, Endian, IWriter, IReader } from "bufferstuff";
|
2024-10-26 14:24:38 +01:00
|
|
|
import { readFileSync, readFile, writeFile, existsSync, mkdirSync, writeFileSync, readdirSync, renameSync } from "fs";
|
|
|
|
import { Console } from "hsconsole";
|
2023-04-11 07:47:56 +01:00
|
|
|
import { deflate, inflate } from "zlib";
|
2024-10-26 14:24:38 +01:00
|
|
|
import Chunk from "./Chunk";
|
|
|
|
import Config from "../config";
|
2024-07-08 09:56:03 +01:00
|
|
|
import FunkyArray from "funky-array";
|
2024-10-26 14:24:38 +01:00
|
|
|
import SaveCompressionType from "./enums/SaveCompressionType";
|
2024-11-25 02:30:27 +00:00
|
|
|
import TileEntityLoader from "./tileentities/TileEntityLoader";
|
|
|
|
import UnsupportedError from "./errors/UnsupportedError";
|
2024-10-26 14:24:38 +01:00
|
|
|
import World from "./World";
|
2023-04-11 07:47:56 +01:00
|
|
|
|
2023-11-09 21:59:45 +00:00
|
|
|
enum FileMagic {
|
|
|
|
Chunk = 0xFC,
|
|
|
|
Info = 0xFD,
|
|
|
|
Player = 0xFE
|
|
|
|
}
|
|
|
|
|
2024-11-25 02:30:27 +00:00
|
|
|
const CHUNK_FILE_VERSION = 2;
|
|
|
|
|
2024-10-26 14:24:38 +01:00
|
|
|
export default class WorldSaveManager {
|
2023-04-11 07:47:56 +01:00
|
|
|
private readonly worldFolderPath;
|
2023-11-07 20:46:17 +00:00
|
|
|
private readonly globalDataPath;
|
2023-04-11 07:47:56 +01:00
|
|
|
private readonly worldPlayerDataFolderPath;
|
|
|
|
private readonly infoFilePath;
|
|
|
|
|
|
|
|
private readonly config:Config;
|
|
|
|
|
|
|
|
public worldCreationDate = new Date();
|
|
|
|
public worldLastLoadDate = new Date();
|
|
|
|
public worldSeed = Number.MIN_VALUE;
|
|
|
|
|
2023-09-04 01:43:11 +01:00
|
|
|
public chunksOnDisk:FunkyArray<number, Array<number>>;
|
2023-11-09 21:59:45 +00:00
|
|
|
public playerDataOnDisk:Array<string>;
|
2023-04-11 07:47:56 +01:00
|
|
|
|
2023-09-04 01:43:11 +01:00
|
|
|
public constructor(config:Config, dimensions:Array<number>, numericalSeed:number) {
|
|
|
|
this.chunksOnDisk = new FunkyArray<number, Array<number>>();
|
2023-11-09 21:59:45 +00:00
|
|
|
this.playerDataOnDisk = new Array<string>();
|
2023-04-11 07:47:56 +01:00
|
|
|
|
|
|
|
this.worldFolderPath = `./${config.worldName}`;
|
|
|
|
this.worldPlayerDataFolderPath = `${this.worldFolderPath}/playerdata`;
|
2023-11-07 20:46:17 +00:00
|
|
|
this.globalDataPath = `${this.worldFolderPath}/data`;
|
2023-04-11 07:47:56 +01:00
|
|
|
this.infoFilePath = `${this.worldFolderPath}/info.hwd`;
|
|
|
|
|
|
|
|
this.config = config;
|
|
|
|
|
|
|
|
// Create world folder if it doesn't exist
|
|
|
|
if (!existsSync(this.worldFolderPath)) {
|
|
|
|
mkdirSync(this.worldFolderPath);
|
2023-11-07 20:46:17 +00:00
|
|
|
mkdirSync(this.globalDataPath);
|
2023-04-11 07:47:56 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
if (existsSync(this.infoFilePath)) {
|
|
|
|
this.readInfoFile();
|
|
|
|
} else {
|
|
|
|
// World info file does not exist
|
|
|
|
this.worldSeed = numericalSeed;
|
|
|
|
this.createInfoFile(numericalSeed);
|
|
|
|
}
|
|
|
|
|
2023-09-04 01:43:11 +01:00
|
|
|
for (const dimension of dimensions) {
|
|
|
|
const chunksArray = new Array<number>();
|
|
|
|
this.chunksOnDisk.set(dimension, chunksArray);
|
|
|
|
|
2023-11-07 20:46:17 +00:00
|
|
|
const dimensionFolderPath = `${this.worldFolderPath}/DIM${dimension}`
|
|
|
|
if (!existsSync(dimensionFolderPath)) {
|
|
|
|
mkdirSync(dimensionFolderPath);
|
|
|
|
mkdirSync(`${dimensionFolderPath}/chunks`);
|
|
|
|
mkdirSync(`${dimensionFolderPath}/data`);
|
2023-09-04 01:43:11 +01:00
|
|
|
} else {
|
2023-11-07 20:46:17 +00:00
|
|
|
const chunkFiles = readdirSync(`${dimensionFolderPath}/chunks`);
|
2023-09-04 01:43:11 +01:00
|
|
|
for (const file of chunkFiles) {
|
|
|
|
if (file.endsWith(".hwc")) {
|
|
|
|
const name = file.split(".")[0];
|
|
|
|
chunksArray.push(parseInt(name.startsWith("-") ? name.replace("-", "-0x") : `0x${name}`));
|
|
|
|
}
|
2023-04-11 07:47:56 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!existsSync(this.worldPlayerDataFolderPath)) {
|
|
|
|
mkdirSync(this.worldPlayerDataFolderPath);
|
|
|
|
}
|
2023-11-09 21:59:45 +00:00
|
|
|
|
|
|
|
const playerDataFiles = readdirSync(this.worldPlayerDataFolderPath);
|
|
|
|
for (const dataFile of playerDataFiles) {
|
|
|
|
if (dataFile.endsWith(".hpd")) {
|
|
|
|
this.playerDataOnDisk.push(dataFile.replace(".hpd", ""));
|
|
|
|
}
|
|
|
|
}
|
2023-04-11 07:47:56 +01:00
|
|
|
}
|
|
|
|
|
2024-11-25 02:30:27 +00:00
|
|
|
private decompressDeflate(buffer:Buffer) {
|
|
|
|
return new Promise<Buffer>((resolve, reject) => {
|
|
|
|
inflate(buffer, (err, data) => {
|
|
|
|
if (err) {
|
|
|
|
return reject(err);
|
|
|
|
}
|
|
|
|
|
|
|
|
resolve(data);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2023-04-11 07:47:56 +01:00
|
|
|
private createInfoFile(numericalSeed:number) {
|
2023-05-02 10:24:48 +01:00
|
|
|
const infoFileWriter = createWriter(Endian.BE, 26);
|
2023-11-09 21:59:45 +00:00
|
|
|
infoFileWriter.writeUByte(FileMagic.Info); // Info File Magic
|
2023-11-07 20:46:17 +00:00
|
|
|
infoFileWriter.writeUByte(2); // File Version
|
2023-04-11 07:47:56 +01:00
|
|
|
infoFileWriter.writeLong(this.worldCreationDate.getTime()); // World creation date
|
|
|
|
infoFileWriter.writeLong(this.worldLastLoadDate.getTime()); // Last load date
|
|
|
|
infoFileWriter.writeLong(numericalSeed);
|
|
|
|
writeFileSync(this.infoFilePath, infoFileWriter.toBuffer());
|
|
|
|
}
|
|
|
|
|
|
|
|
private readInfoFile() {
|
2023-05-02 10:24:48 +01:00
|
|
|
const infoFileReader = createReader(Endian.BE, readFileSync(this.infoFilePath));
|
2023-04-11 07:47:56 +01:00
|
|
|
const fileMagic = infoFileReader.readUByte();
|
2023-11-09 21:59:45 +00:00
|
|
|
if (fileMagic !== FileMagic.Info) {
|
2023-04-11 07:47:56 +01:00
|
|
|
throw new Error("World info file is invalid");
|
|
|
|
}
|
|
|
|
|
|
|
|
const fileVersion = infoFileReader.readByte();
|
2023-11-07 20:46:17 +00:00
|
|
|
// v0, v1 and v2 all contain the same data apart from version numbers
|
|
|
|
// All that changed between them was the folder format.
|
|
|
|
if (fileVersion === 0 || fileVersion === 1 || fileVersion === 2) {
|
2023-04-11 07:47:56 +01:00
|
|
|
this.worldCreationDate = new Date(Number(infoFileReader.readLong()));
|
|
|
|
infoFileReader.readLong(); // Last load time is currently ignored
|
|
|
|
this.worldSeed = Number(infoFileReader.readLong());
|
2023-09-04 01:43:11 +01:00
|
|
|
|
|
|
|
// Upgrade v0 to v1
|
|
|
|
if (fileVersion === 0) {
|
|
|
|
Console.printInfo("Upgrading world to format v1 from v0");
|
|
|
|
renameSync(`${this.worldFolderPath}/chunks`, `${this.worldFolderPath}/DIM0`);
|
|
|
|
this.createInfoFile(this.worldSeed);
|
|
|
|
}
|
2023-11-07 20:46:17 +00:00
|
|
|
// Upgrade v1 to v2
|
|
|
|
if (fileVersion === 1) {
|
|
|
|
Console.printInfo("Upgrading world to format v2 from v1");
|
|
|
|
const files = readdirSync(`${this.worldFolderPath}/`);
|
|
|
|
for (const file of files) {
|
|
|
|
if (file.startsWith("DIM")) {
|
|
|
|
renameSync(`${this.worldFolderPath}/${file}`, `${this.worldFolderPath}/OLD${file}`);
|
|
|
|
mkdirSync(`${this.worldFolderPath}/${file}`);
|
|
|
|
mkdirSync(`${this.worldFolderPath}/${file}/data`);
|
|
|
|
renameSync(`${this.worldFolderPath}/OLD${file}`, `${this.worldFolderPath}/${file}/chunks`);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
this.createInfoFile(this.worldSeed);
|
|
|
|
}
|
2023-04-11 07:47:56 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
public writeChunkToDisk(chunk:Chunk) {
|
2024-11-25 02:30:27 +00:00
|
|
|
return new Promise<boolean>(async (resolve, reject) => {
|
2023-04-17 02:05:01 +01:00
|
|
|
const saveType = this.config.saveCompression;
|
2023-05-02 10:24:48 +01:00
|
|
|
const chunkFileWriter = createWriter(Endian.BE, 10);
|
2023-11-09 21:59:45 +00:00
|
|
|
chunkFileWriter.writeUByte(FileMagic.Chunk); // Chunk File Magic
|
2024-11-25 02:30:27 +00:00
|
|
|
chunkFileWriter.writeUByte(CHUNK_FILE_VERSION); // File Version
|
2023-04-11 07:47:56 +01:00
|
|
|
chunkFileWriter.writeUByte(saveType); // Save compression type
|
|
|
|
chunkFileWriter.writeUByte(16); // Chunk X
|
|
|
|
chunkFileWriter.writeUByte(128); // Chunk Y
|
|
|
|
chunkFileWriter.writeUByte(16); // Chunk Z
|
|
|
|
|
2024-11-25 02:30:27 +00:00
|
|
|
const chunkDataCombined = createWriter(Endian.BE)
|
2023-08-20 01:18:05 +01:00
|
|
|
.writeBuffer(Buffer.from(chunk.getBlockData()))
|
2023-06-19 18:29:16 +01:00
|
|
|
.writeBuffer(chunk.getMetadataBuffer())
|
|
|
|
.writeBuffer(chunk.getBlockLightBuffer())
|
2024-11-25 02:30:27 +00:00
|
|
|
.writeBuffer(chunk.getSkyLightBuffer());
|
|
|
|
|
|
|
|
chunkDataCombined.writeUShort(chunk.tileEntities.length);
|
|
|
|
await chunk.tileEntities.forEach(tileEntity => {
|
|
|
|
tileEntity.toSave(chunkDataCombined);
|
|
|
|
});
|
|
|
|
|
|
|
|
const chunkData = chunkDataCombined.toBuffer();
|
2023-04-11 07:47:56 +01:00
|
|
|
|
2023-09-04 01:43:11 +01:00
|
|
|
const codArr = this.chunksOnDisk.get(chunk.world.dimension);
|
2023-04-11 07:47:56 +01:00
|
|
|
if (saveType === SaveCompressionType.NONE) {
|
|
|
|
chunkFileWriter.writeInt(chunkData.length); // Data length
|
|
|
|
chunkFileWriter.writeBuffer(chunkData); // Chunk data
|
|
|
|
|
2023-11-07 20:46:17 +00:00
|
|
|
writeFile(`${this.worldFolderPath}/DIM${chunk.world.dimension}/chunks/${Chunk.CreateCoordPair(chunk.x, chunk.z).toString(16)}.hwc`, chunkFileWriter.toBuffer(), () => {
|
2023-04-12 23:01:23 +01:00
|
|
|
const cPair = Chunk.CreateCoordPair(chunk.x, chunk.z);
|
2023-09-04 01:43:11 +01:00
|
|
|
|
|
|
|
if (!codArr?.includes(cPair)) {
|
|
|
|
codArr?.push(cPair);
|
2023-04-12 23:01:23 +01:00
|
|
|
}
|
|
|
|
|
2023-04-11 07:47:56 +01:00
|
|
|
resolve(true);
|
|
|
|
});
|
|
|
|
} else if (saveType === SaveCompressionType.DEFLATE) {
|
|
|
|
deflate(chunkData, (err, data) => {
|
|
|
|
if (err) {
|
|
|
|
return reject(err);
|
|
|
|
}
|
|
|
|
|
|
|
|
chunkFileWriter.writeInt(data.length);
|
|
|
|
chunkFileWriter.writeBuffer(data);
|
|
|
|
|
2023-11-07 20:46:17 +00:00
|
|
|
writeFile(`${this.worldFolderPath}/DIM${chunk.world.dimension}/chunks/${Chunk.CreateCoordPair(chunk.x, chunk.z).toString(16)}.hwc`, chunkFileWriter.toBuffer(), () => {
|
2023-04-11 07:47:56 +01:00
|
|
|
const cPair = Chunk.CreateCoordPair(chunk.x, chunk.z);
|
2023-09-04 01:43:11 +01:00
|
|
|
if (!codArr?.includes(cPair)) {
|
|
|
|
codArr?.push(cPair);
|
2023-04-11 07:47:56 +01:00
|
|
|
}
|
|
|
|
//console.log(`Wrote ${chunk.x},${chunk.z} to disk`);
|
|
|
|
resolve(true);
|
|
|
|
});
|
|
|
|
})
|
|
|
|
} else if (saveType === SaveCompressionType.XZ) {
|
|
|
|
// TODO: Implement XZ chunk saving
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
readChunkFromDisk(world:World, x:number, z:number) {
|
|
|
|
return new Promise<Chunk>((resolve, reject) => {
|
2024-11-25 02:30:27 +00:00
|
|
|
readFile(`${this.worldFolderPath}/DIM${world.dimension}/chunks/${Chunk.CreateCoordPair(x, z).toString(16)}.hwc`, async (err, data) => {
|
2023-04-11 07:47:56 +01:00
|
|
|
if (err) {
|
|
|
|
return reject(err);
|
|
|
|
}
|
|
|
|
|
2023-05-02 10:24:48 +01:00
|
|
|
const chunkFileReader = createReader(Endian.BE, data);
|
2023-04-11 07:47:56 +01:00
|
|
|
|
|
|
|
// Check file validity
|
2023-11-09 21:59:45 +00:00
|
|
|
if (chunkFileReader.readUByte() !== FileMagic.Chunk) {
|
2023-04-11 07:47:56 +01:00
|
|
|
return reject(new Error("Chunk file is invalid"));
|
|
|
|
}
|
|
|
|
|
|
|
|
const fileVersion = chunkFileReader.readUByte();
|
2024-11-25 02:30:27 +00:00
|
|
|
if (fileVersion === 0 || fileVersion === 1 || fileVersion === 2) {
|
2023-04-11 07:47:56 +01:00
|
|
|
const saveCompressionType:SaveCompressionType = chunkFileReader.readUByte();
|
|
|
|
const chunkX = chunkFileReader.readUByte();
|
|
|
|
const chunkY = chunkFileReader.readUByte();
|
|
|
|
const chunkZ = chunkFileReader.readUByte();
|
2024-11-25 02:30:27 +00:00
|
|
|
const chunkDataByteSize = chunkX * chunkZ * chunkY;
|
2023-04-11 07:47:56 +01:00
|
|
|
|
|
|
|
const contentLength = chunkFileReader.readInt();
|
2024-11-25 02:30:27 +00:00
|
|
|
let chunkData: IReader;
|
2023-04-11 07:47:56 +01:00
|
|
|
if (saveCompressionType === SaveCompressionType.NONE) {
|
2024-11-25 02:30:27 +00:00
|
|
|
chunkData = createReader(Endian.BE, chunkFileReader.readBuffer(contentLength));
|
2023-04-11 07:47:56 +01:00
|
|
|
} else if (saveCompressionType === SaveCompressionType.DEFLATE) {
|
2024-11-25 02:30:27 +00:00
|
|
|
chunkData = createReader(Endian.BE, await this.decompressDeflate(chunkFileReader.readBuffer(contentLength)));
|
|
|
|
} else {
|
|
|
|
throw new UnsupportedError(`Unsupported chunk compression type`);
|
2023-04-11 07:47:56 +01:00
|
|
|
}
|
2023-05-02 08:50:49 +01:00
|
|
|
|
2024-11-25 02:30:27 +00:00
|
|
|
let chunk:Chunk;
|
|
|
|
if (fileVersion === 0) {
|
|
|
|
chunk = new Chunk(
|
|
|
|
world, x, z,
|
|
|
|
chunkData.readUint8Array(chunkDataByteSize), // Block Data
|
|
|
|
chunkData.readUint8Array(chunkDataByteSize / 2) // Block Metadata
|
|
|
|
);
|
|
|
|
} else if (fileVersion === 1 || fileVersion === 2) {
|
|
|
|
chunk = new Chunk(
|
|
|
|
world, x, z,
|
|
|
|
chunkData.readUint8Array(chunkDataByteSize), // Block Data
|
|
|
|
chunkData.readUint8Array(chunkDataByteSize / 2), // Block Metadata
|
|
|
|
chunkData.readUint8Array(chunkDataByteSize / 2), // Block Light
|
|
|
|
chunkData.readUint8Array(chunkDataByteSize / 2) // Sky Light
|
|
|
|
);
|
|
|
|
} else {
|
|
|
|
throw new UnsupportedError(`Unsupported save file version: ${fileVersion}`);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (fileVersion === 2) {
|
|
|
|
const tileEntityCount = chunkData.readUShort();
|
|
|
|
for (let i = 0; i < tileEntityCount; i++) {
|
|
|
|
const tileEntity = TileEntityLoader.FromSave(chunkData);
|
|
|
|
chunk.tileEntities.set(tileEntity.pos.x << 11 | tileEntity.pos.z << 7 | tileEntity.pos.y, tileEntity);
|
|
|
|
}
|
2023-05-02 08:50:49 +01:00
|
|
|
}
|
2024-11-25 02:30:27 +00:00
|
|
|
|
|
|
|
resolve(chunk);
|
|
|
|
} else {
|
|
|
|
throw new UnsupportedError(`Unsupported save file version: ${fileVersion}`);
|
2023-04-11 07:47:56 +01:00
|
|
|
}
|
|
|
|
});
|
|
|
|
});
|
|
|
|
}
|
2023-11-09 21:59:45 +00:00
|
|
|
|
|
|
|
writePlayerSaveToDisk(username:string, playerData:IWriter) {
|
|
|
|
return new Promise<boolean>((resolve, reject) => {
|
|
|
|
const playerDataWriter = createWriter(Endian.BE);
|
|
|
|
playerDataWriter.writeUByte(FileMagic.Player); // File magic
|
|
|
|
playerDataWriter.writeUByte(0); // File version
|
|
|
|
playerDataWriter.writeBuffer(playerData.toBuffer()); // Player data
|
|
|
|
|
|
|
|
writeFile(`${this.worldPlayerDataFolderPath}/${username}.hpd`, playerDataWriter.toBuffer(), (err) => {
|
|
|
|
if (err) {
|
|
|
|
return reject(err);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!this.playerDataOnDisk.includes(username)) {
|
|
|
|
this.playerDataOnDisk.push(username);
|
|
|
|
}
|
|
|
|
|
|
|
|
resolve(true);
|
|
|
|
})
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
readPlayerDataFromDisk(username:string) {
|
|
|
|
return new Promise<IReader>((resolve, reject) => {
|
|
|
|
readFile(`${this.worldPlayerDataFolderPath}/${username}.hpd`, (err, data) => {
|
|
|
|
if (err) {
|
|
|
|
return reject(err);
|
|
|
|
}
|
|
|
|
|
|
|
|
const reader = createReader(Endian.BE, data);
|
|
|
|
if (reader.readUByte() !== FileMagic.Player) {
|
|
|
|
return reject(new Error("Player data file is invalid"));
|
|
|
|
}
|
|
|
|
|
|
|
|
const fileVersion = reader.readUByte();
|
|
|
|
if (fileVersion === 0) {
|
|
|
|
resolve(reader);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
});
|
|
|
|
}
|
2023-04-11 07:47:56 +01:00
|
|
|
}
|