140 lines
No EOL
4.2 KiB
TypeScript
140 lines
No EOL
4.2 KiB
TypeScript
import { join } from "path";
|
|
|
|
const SECTOR_SIZE = 0x800;
|
|
const MAGIC = "MICROSOFT*XBOX*MEDIA";
|
|
|
|
const PARTITION_OFFSETS: Record<string, number> = {
|
|
XSF: 0x0,
|
|
GDF: 0xFD90000,
|
|
XGD3: 0x2080000,
|
|
};
|
|
|
|
async function readBytes (file: ReturnType<typeof Bun.file>, offset: number, length: number): Promise<Buffer>
|
|
{
|
|
return Buffer.from(await file.slice(offset, offset + length).arrayBuffer());
|
|
}
|
|
|
|
async function parseTitleIdFromXexReader (
|
|
read: (offset: number, length: number) => Promise<Buffer>
|
|
): Promise<string>
|
|
{
|
|
// Read just the fixed header (magic + flags + offsets + header count)
|
|
const header = await read(0, 0x18);
|
|
if (header.toString("ascii", 0, 4) !== "XEX2")
|
|
{
|
|
throw new Error("Not a valid XEX2 file");
|
|
}
|
|
|
|
const headerCount = header.readUInt32BE(0x14);
|
|
const EXEC_INFO_KEY = 0x40006;
|
|
|
|
// Read the optional header table
|
|
const table = await read(0x18, headerCount * 8);
|
|
|
|
for (let i = 0; i < headerCount; i++)
|
|
{
|
|
const key = table.readUInt32BE(i * 8);
|
|
const valueOrOffset = table.readUInt32BE(i * 8 + 4);
|
|
|
|
if (key === EXEC_INFO_KEY)
|
|
{
|
|
// valueOrOffset is a file offset — read the exec info struct there
|
|
// TitleID is at +0x0C within it
|
|
const execInfo = await read(valueOrOffset, 0x18);
|
|
return execInfo.readUInt32BE(0x0C)
|
|
.toString(16).toUpperCase().padStart(8, "0");
|
|
}
|
|
}
|
|
|
|
throw new Error("Execution info header not found in XEX");
|
|
}
|
|
|
|
async function titleIdFromXexFile (xexPath: string): Promise<string>
|
|
{
|
|
const file = Bun.file(xexPath);
|
|
return parseTitleIdFromXexReader((offset, length) =>
|
|
readBytes(file, offset, length)
|
|
);
|
|
}
|
|
|
|
async function titleIdFromIso (isoPath: string): Promise<string>
|
|
{
|
|
const file = Bun.file(isoPath);
|
|
const fileSize = file.size;
|
|
|
|
for (const partitionOffset of Object.values(PARTITION_OFFSETS))
|
|
{
|
|
const vdOffset = partitionOffset + 0x20 * SECTOR_SIZE;
|
|
if (vdOffset + 28 > fileSize) continue;
|
|
|
|
const vd = await readBytes(file, vdOffset, 28);
|
|
if (vd.toString("ascii", 0, 20) !== MAGIC) continue;
|
|
|
|
const rootSector = vd.readUInt32LE(20);
|
|
const rootSize = vd.readUInt32LE(24);
|
|
const rootOffset = partitionOffset + rootSector * SECTOR_SIZE;
|
|
const dir = await readBytes(file, rootOffset, rootSize);
|
|
|
|
let pos = 0;
|
|
while (pos < dir.length)
|
|
{
|
|
if (dir[pos] === 0xFF) break;
|
|
if (pos + 14 > dir.length) break;
|
|
|
|
const nameLen = dir[pos + 13];
|
|
if (nameLen === 0 || nameLen === 0xFF) break;
|
|
if (pos + 14 + nameLen > dir.length) break;
|
|
|
|
const name = dir.toString("ascii", pos + 14, pos + 14 + nameLen);
|
|
const fileSector = dir.readUInt32LE(pos + 4);
|
|
|
|
if (name.toLowerCase() === "default.xex")
|
|
{
|
|
const xexBase = partitionOffset + fileSector * SECTOR_SIZE;
|
|
// Reader that translates relative XEX offsets to absolute ISO offsets
|
|
return parseTitleIdFromXexReader((offset, length) =>
|
|
readBytes(file, xexBase + offset, length)
|
|
);
|
|
}
|
|
|
|
const entryLen = 14 + nameLen;
|
|
pos += (entryLen + 3) & ~3;
|
|
}
|
|
}
|
|
|
|
throw new Error("Not a valid Xbox 360 ISO or default.xex not found");
|
|
}
|
|
|
|
async function titleIdFromFolder (folderPath: string): Promise<string>
|
|
{
|
|
return titleIdFromXexFile(join(folderPath, "default.xex"));
|
|
}
|
|
|
|
type XeniaRomType = "iso" | "xex" | "folder";
|
|
|
|
function detectRomType (romPath: string): XeniaRomType
|
|
{
|
|
const lower = romPath.toLowerCase();
|
|
if (lower.endsWith(".iso")) return "iso";
|
|
if (lower.endsWith(".xex")) return "xex";
|
|
return "folder"; // extracted game folder containing default.xex
|
|
}
|
|
|
|
async function getTitleId (romPath: string): Promise<string>
|
|
{
|
|
switch (detectRomType(romPath))
|
|
{
|
|
case "iso": return titleIdFromIso(romPath);
|
|
case "xex": return titleIdFromXexFile(romPath);
|
|
case "folder": return titleIdFromFolder(romPath);
|
|
}
|
|
}
|
|
|
|
export async function getXeniaSavePaths (
|
|
romPath: string,
|
|
xeniaDir: string
|
|
): Promise<string>
|
|
{
|
|
const titleId = await getTitleId(romPath);
|
|
return join(xeniaDir, titleId);
|
|
}; |