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" ;
2024-11-25 22:28:33 +00:00
import Block from "./blocks/Block" ;
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 ) ;
2024-11-25 22:28:33 +00:00
const blockAtTileEntity = chunk . getBlockId ( tileEntity . pos . x , tileEntity . pos . y , tileEntity . pos . z ) ;
if ( blockAtTileEntity === tileEntity . forBlock . blockId ) {
chunk . tileEntities . set ( tileEntity . pos . x << 11 | tileEntity . pos . z << 7 | tileEntity . pos . y , tileEntity ) ;
} else {
Console . printWarn ( ` Tile entity in chunk ${ chunk . x } , ${ chunk . z } block ${ tileEntity . pos } has no associated block of type ${ tileEntity . forBlock . blockName } , instead found ${ Block . blockNames [ blockAtTileEntity ] ? ? "Air" } . Skipping... ` ) ;
}
2024-11-25 02:30:27 +00:00
}
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
}