From b5667532ca926e16ec0bd03d55abaec0871eaf56 Mon Sep 17 00:00:00 2001 From: /home/neo <158327205+neoapps-dev@users.noreply.github.com> Date: Sun, 28 Jun 2026 20:36:29 +0300 Subject: [PATCH] feat: savedata parsing and 360revived new link (#101) --- src/hooks/useGameManager.ts | 2 +- src/services/SaveService.ts | 622 ++++++++++++++++++++++++++++++++++++ src/types/save.ts | 128 ++++++++ 3 files changed, 751 insertions(+), 1 deletion(-) create mode 100644 src/services/SaveService.ts create mode 100644 src/types/save.ts diff --git a/src/hooks/useGameManager.ts b/src/hooks/useGameManager.ts index 8b69cc5..2236caa 100644 --- a/src/hooks/useGameManager.ts +++ b/src/hooks/useGameManager.ts @@ -51,7 +51,7 @@ const BASE_EDITIONS = [ id: "360revived", name: "360 Revived", desc: "PC port of Xbox 360 Edition TU19", - url: "https://github.com/BluTac10/360Revived/releases/download/nightly/LCEWindows64.zip", + url: "https://github.com/BlackHoleSpirit/360-Revived/releases/download/nightly/360-Revived.zip", titleImage: "/images/minecraft_title_360revived.png", supportsSlimSkins: false, logo: "/images/360_revived.png", diff --git a/src/services/SaveService.ts b/src/services/SaveService.ts new file mode 100644 index 0000000..c92abc2 --- /dev/null +++ b/src/services/SaveService.ts @@ -0,0 +1,622 @@ +import pako from "pako"; +import type { + NbtValue, + NbtByte, + NbtShort, + NbtInt, + NbtLong, + NbtFloat, + NbtDouble, + NbtString, + NbtList, + NbtCompound, + NbtFile, + ContainerEntry, + ParsedContainer, + LoadedSave, +} from "../types/save"; +import { TagType } from "../types/save"; +const FILE_ENTRY_SIZE = 144; +const INNER_HEADER_SIZE = 12; +class NbtReader { + private view: DataView; + private pos = 0; + private raw: Uint8Array; + constructor(data: Uint8Array) { + this.raw = data; + this.view = new DataView(data.buffer, data.byteOffset, data.byteLength); + } + + private u8() { + return this.view.getUint8(this.pos++); + } + private i8() { + return this.view.getInt8(this.pos++); + } + private i16() { + const v = this.view.getInt16(this.pos, false); + this.pos += 2; + return v; + } + private u16() { + const v = this.view.getUint16(this.pos, false); + this.pos += 2; + return v; + } + private i32() { + const v = this.view.getInt32(this.pos, false); + this.pos += 4; + return v; + } + private f32() { + const v = this.view.getFloat32(this.pos, false); + this.pos += 4; + return v; + } + private f64() { + const v = this.view.getFloat64(this.pos, false); + this.pos += 8; + return v; + } + + private i64(): bigint { + const hi = this.view.getUint32(this.pos, false); + const lo = this.view.getUint32(this.pos + 4, false); + this.pos += 8; + const u = (BigInt(hi) << 32n) | BigInt(lo); + return u >= 1n << 63n ? u - (1n << 64n) : u; + } + + private readString(): string { + const len = this.u16(); + const bytes = this.raw.subarray(this.pos, this.pos + len); + this.pos += len; + return new TextDecoder().decode(bytes); + } + + private readPayload(type: TagType): NbtValue { + switch (type) { + case TagType.Byte: + return { type: TagType.Byte, value: this.i8() }; + case TagType.Short: + return { type: TagType.Short, value: this.i16() }; + case TagType.Int: + return { type: TagType.Int, value: this.i32() }; + case TagType.Long: + return { type: TagType.Long, value: this.i64() }; + case TagType.Float: + return { type: TagType.Float, value: this.f32() }; + case TagType.Double: + return { type: TagType.Double, value: this.f64() }; + case TagType.String: + return { type: TagType.String, value: this.readString() }; + case TagType.ByteArray: { + const n = this.i32(); + const v = this.raw.slice(this.pos, this.pos + n); + this.pos += n; + return { type: TagType.ByteArray, value: v }; + } + case TagType.IntArray: { + const n = this.i32(); + const v = new Int32Array(n); + for (let i = 0; i < n; i++) v[i] = this.i32(); + return { type: TagType.IntArray, value: v }; + } + case TagType.LongArray: { + const n = this.i32(); + const v = new BigInt64Array(n); + for (let i = 0; i < n; i++) v[i] = this.i64(); + return { type: TagType.LongArray, value: v }; + } + case TagType.List: { + const elemType = this.u8() as TagType; + const n = this.i32(); + const items: NbtValue[] = []; + for (let i = 0; i < n; i++) items.push(this.readPayload(elemType)); + return { type: TagType.List, elementType: elemType, items }; + } + case TagType.Compound: { + const tags: Record = {}; + for (;;) { + const tagType = this.u8() as TagType; + if (tagType === TagType.End) break; + const name = this.readString(); + tags[name] = this.readPayload(tagType); + } + return { type: TagType.Compound, tags }; + } + default: + throw new Error(`Unknown NBT tag type: ${type}`); + } + } + + readFile(): NbtFile { + const type = this.u8() as TagType; + if (type !== TagType.Compound) + throw new Error(`Root tag must be Compound, got type ${type}`); + const rootName = this.readString(); + const root = this.readPayload(TagType.Compound) as NbtCompound; + return { rootName, root }; + } +} + +class NbtWriter { + private chunks: Uint8Array[] = []; + private push(b: Uint8Array) { + this.chunks.push(b); + } + + private u8(v: number) { + const b = new Uint8Array(1); + b[0] = v & 0xff; + this.push(b); + } + private i16(v: number) { + const b = new Uint8Array(2); + new DataView(b.buffer).setInt16(0, v, false); + this.push(b); + } + private u16(v: number) { + const b = new Uint8Array(2); + new DataView(b.buffer).setUint16(0, v, false); + this.push(b); + } + private i32(v: number) { + const b = new Uint8Array(4); + new DataView(b.buffer).setInt32(0, v, false); + this.push(b); + } + private f32(v: number) { + const b = new Uint8Array(4); + new DataView(b.buffer).setFloat32(0, v, false); + this.push(b); + } + private f64(v: number) { + const b = new Uint8Array(8); + new DataView(b.buffer).setFloat64(0, v, false); + this.push(b); + } + + private i64(v: bigint) { + const b = new Uint8Array(8); + const dv = new DataView(b.buffer); + const u = v < 0n ? v + (1n << 64n) : v; + dv.setUint32(0, Number(u >> 32n), false); + dv.setUint32(4, Number(u & 0xffffffffn), false); + this.push(b); + } + + private writeString(v: string) { + const enc = new TextEncoder().encode(v); + this.u16(enc.length); + this.push(enc); + } + + private writePayload(value: NbtValue) { + switch (value.type) { + case TagType.Byte: + this.u8(value.value & 0xff); + break; + case TagType.Short: + this.i16(value.value); + break; + case TagType.Int: + this.i32(value.value); + break; + case TagType.Long: + this.i64(value.value); + break; + case TagType.Float: + this.f32(value.value); + break; + case TagType.Double: + this.f64(value.value); + break; + case TagType.String: + this.writeString(value.value); + break; + case TagType.ByteArray: { + this.i32(value.value.length); + this.push(value.value); + break; + } + case TagType.IntArray: { + this.i32(value.value.length); + const b = new Uint8Array(value.value.length * 4); + const dv = new DataView(b.buffer); + for (let i = 0; i < value.value.length; i++) + dv.setInt32(i * 4, value.value[i], false); + this.push(b); + break; + } + case TagType.LongArray: { + this.i32(value.value.length); + for (let i = 0; i < value.value.length; i++) this.i64(value.value[i]); + break; + } + case TagType.List: { + this.u8(value.elementType); + this.i32(value.items.length); + for (const item of value.items) this.writePayload(item); + break; + } + case TagType.Compound: { + for (const [name, child] of Object.entries(value.tags)) { + this.u8(child.type); + this.writeString(name); + this.writePayload(child); + } + this.u8(TagType.End); + break; + } + } + } + + writeFile(file: NbtFile): Uint8Array { + this.u8(TagType.Compound); + this.writeString(file.rootName); + this.writePayload(file.root); + const total = this.chunks.reduce((s, c) => s + c.length, 0); + const out = new Uint8Array(total); + let pos = 0; + for (const c of this.chunks) { + out.set(c, pos); + pos += c.length; + } + return out; + } +} + +function getTag( + tags: Record, + key: string, + type: T["type"], +): T | undefined { + const v = tags[key]; + return v?.type === type ? (v as T) : undefined; +} + +const get = { + byte: (t: Record, k: string) => + getTag(t, k, TagType.Byte)?.value, + short: (t: Record, k: string) => + getTag(t, k, TagType.Short)?.value, + int: (t: Record, k: string) => + getTag(t, k, TagType.Int)?.value, + long: (t: Record, k: string) => + getTag(t, k, TagType.Long)?.value, + float: (t: Record, k: string) => + getTag(t, k, TagType.Float)?.value, + double: (t: Record, k: string) => + getTag(t, k, TagType.Double)?.value, + str: (t: Record, k: string) => + getTag(t, k, TagType.String)?.value, + list: (t: Record, k: string) => + getTag(t, k, TagType.List), + compound: (t: Record, k: string) => + getTag(t, k, TagType.Compound), +}; + +function cloneNbt(file: NbtFile): NbtFile { + return JSON.parse( + JSON.stringify(file, (_k, v) => + typeof v === "bigint" ? { __bigint__: v.toString() } : v, + ), + (_k, v) => + v && typeof v === "object" && "__bigint__" in v + ? BigInt(v.__bigint__) + : v, + ) as NbtFile; +} + +const nbt = { + byte: (v: number): NbtByte => ({ type: TagType.Byte, value: v }), + short: (v: number): NbtShort => ({ type: TagType.Short, value: v }), + int: (v: number): NbtInt => ({ type: TagType.Int, value: v }), + long: (v: bigint): NbtLong => ({ type: TagType.Long, value: v }), + float: (v: number): NbtFloat => ({ type: TagType.Float, value: v }), + str: (v: string): NbtString => ({ type: TagType.String, value: v }), + compound: (tags: Record): NbtCompound => ({ + type: TagType.Compound, + tags, + }), + list: (elementType: TagType, items: NbtValue[]): NbtList => ({ + type: TagType.List, + elementType, + items, + }), +}; + +function isConsoleContainer(bytes: Uint8Array): boolean { + if (bytes.length < 8) return false; + const dv = new DataView(bytes.buffer, bytes.byteOffset); + return ( + dv.getUint32(0, true) === 0 && + dv.getUint32(4, true) > 0 && + dv.getUint32(4, true) <= 256 * 1024 * 1024 + ); +} + +function decompressInner(bytes: Uint8Array): Uint8Array { + return pako.inflate(bytes.subarray(8)); +} + +function readEntries(buf: Uint8Array): { + entries: ContainerEntry[]; + origVersion: number; + curVersion: number; +} { + const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength); + const headerOffset = dv.getUint32(0, true); + const headerSize = dv.getUint32(4, true); + const origVersion = dv.getInt16(8, true); + const curVersion = dv.getInt16(10, true); + const entries: ContainerEntry[] = []; + for (let i = 0; i < headerSize; i++) { + const base = headerOffset + i * FILE_ENTRY_SIZE; + if (base + FILE_ENTRY_SIZE > buf.length) break; + const nameBytes = buf.subarray(base, base + 128); + let name: string; + try { + name = new TextDecoder("utf-16le").decode(nameBytes).replace(/\0+$/, ""); + } catch { + continue; + } + if (!name) continue; + const length = dv.getUint32(base + 128, true); + const startOffset = dv.getUint32(base + 132, true); + const lastModLow = dv.getUint32(base + 136, true); + const lastModHigh = dv.getUint32(base + 140, true); + if (startOffset === 0 || length === 0 || startOffset + length > buf.length) + continue; + entries.push({ + name, + data: buf.slice(startOffset, startOffset + length), + origStartOffset: startOffset, + lastModLow, + lastModHigh, + }); + } + + return { entries, origVersion, curVersion }; +} + +function parseConsoleContainer(bytes: Uint8Array): ParsedContainer { + const buf = decompressInner(bytes); + const { entries, origVersion, curVersion } = readEntries(buf); + return { entries, origVersion, curVersion }; +} + +function encodeUtf16Le(str: string): Uint8Array { + const buf = new Uint8Array(str.length * 2); + for (let i = 0; i < str.length; i++) { + const code = str.charCodeAt(i); + buf[i * 2] = code & 0xff; + buf[i * 2 + 1] = (code >> 8) & 0xff; + } + return buf; +} + +function rebuildConsoleContainer( + originalBytes: Uint8Array, + container: ParsedContainer, + replacements: Record, +): Uint8Array { + const buf = decompressInner(originalBytes); + const dv0 = new DataView(buf.buffer, buf.byteOffset); + const origVersion = dv0.getInt16(8, true); + const curVersion = dv0.getInt16(10, true); + const sorted = [...container.entries].sort( + (a, b) => a.origStartOffset - b.origStartOffset, + ); + const headerBuf = new Uint8Array(INNER_HEADER_SIZE); + const chunks: Uint8Array[] = [headerBuf]; + let offset = INNER_HEADER_SIZE; + const newEntries: Array< + ContainerEntry & { newStart: number; newLength: number } + > = []; + + for (const entry of sorted) { + const newStart = offset; + const data = replacements[entry.name] ?? entry.data; + chunks.push(data); + offset += data.length; + newEntries.push({ ...entry, newStart, newLength: data.length }); + } + + const newHeaderOffset = offset; + for (const entry of newEntries) { + const entryBuf = new Uint8Array(FILE_ENTRY_SIZE); + const edv = new DataView(entryBuf.buffer); + const nameUtf16 = encodeUtf16Le(entry.name); + const namePad = new Uint8Array(128); + namePad.set(nameUtf16.subarray(0, 128)); + entryBuf.set(namePad, 0); + edv.setUint32(128, entry.newLength, true); + edv.setUint32(132, entry.newStart, true); + edv.setUint32(136, entry.lastModLow, true); + edv.setUint32(140, entry.lastModHigh, true); + chunks.push(entryBuf); + } + + const hdv = new DataView(headerBuf.buffer); + hdv.setUint32(0, newHeaderOffset, true); + hdv.setUint32(4, newEntries.length, true); + hdv.setInt16(8, origVersion, true); + hdv.setInt16(10, curVersion, true); + const totalSize = chunks.reduce((s, c) => s + c.length, 0); + const newBuf = new Uint8Array(totalSize); + let pos = 0; + for (const c of chunks) { + newBuf.set(c, pos); + pos += c.length; + } + + const compressed = pako.deflate(newBuf); + const result = new Uint8Array(8 + compressed.length); + const rdv = new DataView(result.buffer); + rdv.setUint32(0, 0, true); + rdv.setUint32(4, newBuf.length, true); + result.set(compressed, 8); + return result; +} + +const ROMAN = ["", "I", "II", "III", "IV", "V"]; +function toRoman(n: number): string { + return ROMAN[n] ?? String(n); +} + +export const SaveService = { + parseNbt(data: Uint8Array): NbtFile { + return new NbtReader(data).readFile(); + }, + + serializeNbt(file: NbtFile): Uint8Array { + return new NbtWriter().writeFile(file); + }, + + cloneNbt, + get, + nbt, + isConsoleContainer, + parseConsoleContainer, + rebuildConsoleContainer, + toRoman, + async loadSaveFile(file: File): Promise { + const bytes = new Uint8Array(await file.arrayBuffer()); + if (isConsoleContainer(bytes)) { + const container = parseConsoleContainer(bytes); + const sorted = [...container.entries].sort( + (a, b) => + (isPlayerFilename(a.name) ? 0 : 1) - + (isPlayerFilename(b.name) ? 0 : 1), + ); + + let playerFilename: string | null = null; + let playerNbt: NbtFile | null = null; + for (const entry of sorted) { + try { + const parsed = new NbtReader(entry.data).readFile(); + if (hasInventory(parsed)) { + playerFilename = entry.name; + playerNbt = parsed; + break; + } + } catch { + //neo: what am i even supposed to do here? + } + } + + if (!playerNbt || !playerFilename) { + throw new Error( + `Save container has ${container.entries.length} embedded files but none contain a player Inventory.\n` + + "Load the world in-game at least once to generate player data, then try again.", + ); + } + + let levelFilename: string | null = null; + let levelNbt: NbtFile | null = null; + const levelEntry = container.entries.find( + (e) => e.name.toLowerCase() === "level.dat", + ); + if (levelEntry) { + try { + levelNbt = new NbtReader(levelEntry.data).readFile(); + levelFilename = levelEntry.name; + } catch { + //neo: at this point the level.dat isn't valid nbt. what do i do lmfao + } + } + + return { + filename: file.name, + format: "console_save", + originalBytes: bytes, + container, + playerFilename, + levelFilename, + playerNbt, + levelNbt, + }; + } + + const playerNbt = new NbtReader(bytes).readFile(); + if (!hasInventory(playerNbt)) { + throw new Error( + "File does not appear to be a player .dat file (no Inventory list found).", + ); + } + return { + filename: file.name, + format: "plain_nbt", + originalBytes: bytes, + container: null, + playerFilename: null, + levelFilename: null, + playerNbt, + levelNbt: null, + }; + }, + + buildSaveBytes(loaded: LoadedSave): Uint8Array { + if (loaded.format === "plain_nbt") { + return new NbtWriter().writeFile(loaded.playerNbt); + } + const replacements: Record = {}; + replacements[loaded.playerFilename!] = new NbtWriter().writeFile( + loaded.playerNbt, + ); + if (loaded.levelNbt && loaded.levelFilename) { + replacements[loaded.levelFilename] = new NbtWriter().writeFile( + loaded.levelNbt, + ); + } + return rebuildConsoleContainer( + loaded.originalBytes, + loaded.container!, + replacements, + ); + }, + + getWorldNameFromMs(bytes: Uint8Array): string | null { + if (!isConsoleContainer(bytes)) { + try { + const nbtFile = new NbtReader(bytes).readFile(); + return get.str(nbtFile.root.tags, "LevelName") ?? null; + } catch { + return null; + } + } + try { + const buf = decompressInner(bytes); + const { entries } = readEntries(buf); + const levelEntry = entries.find( + (e) => e.name.toLowerCase() === "level.dat", + ); + if (!levelEntry) return null; + const nbtFile = new NbtReader(levelEntry.data).readFile(); + const root = nbtFile.root.tags; + const data = + root["Data"]?.type === TagType.Compound + ? (root["Data"] as NbtCompound).tags + : root; + return get.str(data, "LevelName") ?? null; + } catch { + return null; + } + }, +}; + +function hasInventory(nbtFile: NbtFile): boolean { + const inv = nbtFile.root.tags["Inventory"]; + return inv?.type === 9; +} + +function isPlayerFilename(name: string): boolean { + const lower = name.toLowerCase(); + return ( + lower.includes("players/") || + lower.startsWith("p_") || + lower.startsWith("n_") + ); +} diff --git a/src/types/save.ts b/src/types/save.ts new file mode 100644 index 0000000..1215347 --- /dev/null +++ b/src/types/save.ts @@ -0,0 +1,128 @@ +export enum TagType { + End = 0, + Byte = 1, + Short = 2, + Int = 3, + Long = 4, + Float = 5, + Double = 6, + ByteArray = 7, + String = 8, + List = 9, + Compound = 10, + IntArray = 11, + LongArray = 12, +} + +export interface NbtByte { + type: TagType.Byte; + value: number; +} +export interface NbtShort { + type: TagType.Short; + value: number; +} +export interface NbtInt { + type: TagType.Int; + value: number; +} +export interface NbtLong { + type: TagType.Long; + value: bigint; +} +export interface NbtFloat { + type: TagType.Float; + value: number; +} +export interface NbtDouble { + type: TagType.Double; + value: number; +} +export interface NbtString { + type: TagType.String; + value: string; +} +export interface NbtByteArray { + type: TagType.ByteArray; + value: Uint8Array; +} +export interface NbtIntArray { + type: TagType.IntArray; + value: Int32Array; +} +export interface NbtLongArray { + type: TagType.LongArray; + value: BigInt64Array; +} +export interface NbtList { + type: TagType.List; + elementType: TagType; + items: NbtValue[]; +} +export interface NbtCompound { + type: TagType.Compound; + tags: Record; +} +export type NbtValue = + | NbtByte + | NbtShort + | NbtInt + | NbtLong + | NbtFloat + | NbtDouble + | NbtString + | NbtByteArray + | NbtIntArray + | NbtLongArray + | NbtList + | NbtCompound; + +export interface NbtFile { + rootName: string; + root: NbtCompound; +} + +export interface ContainerEntry { + name: string; + data: Uint8Array; + origStartOffset: number; + lastModLow: number; + lastModHigh: number; +} + +export interface ParsedContainer { + origVersion: number; + curVersion: number; + entries: ContainerEntry[]; +} + +export interface LoadedSave { + filename: string; + format: "console_save" | "plain_nbt"; + originalBytes: Uint8Array; + container: ParsedContainer | null; + playerFilename: string | null; + levelFilename: string | null; + playerNbt: NbtFile; + levelNbt: NbtFile | null; +} + +export interface InventoryItem { + slot: number; + id: number; + count: number; + damage: number; + enchants: EnchantEntry[]; +} + +export interface EnchantEntry { + id: number; + lvl: number; +} + +export interface EnchantDef { + id: number; + name: string; + maxLevel: number; + applies: ("sword" | "tool" | "armor" | "bow" | "fishing" | "all")[]; +}