@nx.js/install-title
Version:
Install a title from an NSP file
278 lines • 12.3 kB
JavaScript
import { u8 } from '@nx.js/util';
import { FsFileSystemType } from '@nx.js/constants';
import { NcmContentId, NcmContentStorage, NcmContentMetaDatabase, NcmContentMetaKey, NcmContentInstallType, NcmContentType, NcmContentMetaHeader, NcmContentInfo, NcmContentStorageRecord, NcmContentMetaType, NcmPatchMetaExtendedHeader, NcmPackagedContentInfo, NcmApplicationMetaExtendedHeader, } from '@nx.js/ncm';
import { NsApplicationRecordType, nsDeleteApplicationRecord, nsInvalidateApplicationControlCache, nsListApplicationRecordContentMeta, nsPushApplicationRecord, } from '@nx.js/ns';
import { parseNsp } from '@tootallnate/nsp';
import { esImportTicket } from './ipc/es';
import { PackagedContentMetaHeader } from './types';
async function* step(name, fn) {
const timeStart = performance.now();
yield { type: 'start', name, timeStart };
try {
return yield* fn();
}
finally {
yield { type: 'end', name, timeStart, timeEnd: performance.now() };
}
}
// Install NSP
export async function* installNsp(data, storageId) {
const contentStorage = NcmContentStorage.open(storageId);
const contentMetaDatabase = NcmContentMetaDatabase.open(storageId);
// The `.nca` files that will be installed
const ncaFiles = new Set();
// Map of title IDs found in the `.cnmt.nca` files and the accompanying
// `NcmContentMetaKey` instances that will be recorded
const titleIdToContentMetaKeysMap = new Map();
const nsp = yield* step('Parse NSP', async function* () {
return parseNsp(data);
});
// Prepare metadata from the `.cnmt.nca` file(s)
const cnmtNcaFiles = nsp.files
.entries()
.filter(([name]) => name.endsWith('.cnmt.nca'));
for (const [name, entry] of cnmtNcaFiles) {
// Install the `.nca` so that we can mount the
// filesystem to read the `.cnmt` file inside
await installNca(name, entry, contentStorage);
// Read the decrypted `.cnmt` file
const cnmtData = await readContentMeta(name, contentStorage);
const contentMetaKey = await installContentMetaRecords(contentMetaDatabase, cnmtData, createMetaContentInfo(name, entry), ncaFiles);
// Add the content meta key to add to the application record later
const cnmtHeader = new PackagedContentMetaHeader(cnmtData);
const titleId = getBaseTitleId(cnmtHeader.titleId, cnmtHeader.type);
let contentMetaKeys = titleIdToContentMetaKeysMap.get(titleId);
if (!contentMetaKeys) {
contentMetaKeys = [];
titleIdToContentMetaKeysMap.set(titleId, contentMetaKeys);
}
contentMetaKeys.push(contentMetaKey);
}
// Install Tickets / Certificates
yield* importTicketCerts(nsp.files);
// Install NCAs files
for (const name of ncaFiles) {
const entry = nsp.files.get(name);
if (!entry) {
throw new Error(`Missing "${name}" file`);
}
await installNca(name, entry, contentStorage);
}
// Push application records
for (const [titleId, contentMetaKeys] of titleIdToContentMetaKeysMap) {
await pushApplicationRecord(titleId, contentMetaKeys, storageId);
}
}
class ContentStoragePlaceholderWriteStream extends WritableStream {
#offset;
constructor(contentStorage, contentId, size) {
const placeholderId = contentStorage.generatePlaceHolderId();
try {
contentStorage.deletePlaceHolder(placeholderId);
}
catch { }
contentStorage.createPlaceHolder(contentId, placeholderId, BigInt(size));
super({
write: (chunk) => {
console.debug(`Writing ${chunk.byteLength} bytes (${this.#offset})`);
contentStorage.writePlaceHolder(placeholderId, this.#offset, chunk);
this.#offset += BigInt(chunk.byteLength);
},
close: () => {
console.debug(`Finished writing ${this.#offset} bytes`);
try {
contentStorage.delete(contentId);
}
catch { }
contentStorage.register(contentId, placeholderId);
},
});
this.#offset = 0n;
}
}
function createContentMetaKey(header) {
const key = new NcmContentMetaKey();
key.id = header.titleId;
key.version = header.version;
key.type = header.type;
key.installType = NcmContentInstallType.Full;
return key;
}
function createMetaContentInfo(name, entry) {
const info = new NcmContentInfo();
info.contentId = NcmContentId.from(name);
info.size = entry.size;
info.contentType = NcmContentType.Meta;
return info;
}
async function installNca(name, entry, contentStorage) {
console.debug(`Installing "${name}" (${entry.size} bytes)`);
const contentId = NcmContentId.from(name);
await entry.data
.stream()
.pipeTo(new ContentStoragePlaceholderWriteStream(contentStorage, contentId, entry.size));
}
async function* importTicketCerts(files) {
const ticketFiles = files.keys().filter((name) => name.endsWith('.tik'));
for (const tikName of ticketFiles) {
yield* step(`Importing ticket "${tikName}"`, async function* () {
const hash = tikName.slice(0, -4);
const tikEntry = files.get(tikName);
if (!tikEntry) {
throw new Error(`Missing "${tikName}" file`);
}
const certName = `${hash}.cert`;
const certEntry = files.get(certName);
if (!certEntry) {
throw new Error(`Missing "${certName}" file`);
}
const tik = await tikEntry.data.arrayBuffer();
const cert = await certEntry.data.arrayBuffer();
console.debug(`Importing "${tikName}" and "${certName}" certificate`);
esImportTicket(tik, cert);
});
}
}
async function readContentMeta(name, contentStorage) {
const contentId = NcmContentId.from(name);
const path = contentStorage.getPath(contentId);
console.debug(`Mounting path "${path}"`);
const fs = Switch.FileSystem.openWithId(0n, FsFileSystemType.ContentMeta, path);
const url = fs.mount();
const files = Switch.readDirSync(url);
if (!files) {
throw new Error('Failed to read directory');
}
const cnmtName = files.find((name) => name.endsWith('.cnmt'));
if (!cnmtName) {
throw new Error('Failed to find ".cnmt" file');
}
console.debug(`Reading "${cnmtName}" file`);
const cnmt = Switch.readFileSync(new URL(cnmtName, url));
if (!cnmt) {
throw new Error('Failed to read ".cnmt" file');
}
return cnmt;
}
async function installContentMetaRecords(contentMetaDatabase, cnmtData, cnmtContentInfo, ncaFilesToInstall) {
const contentInfos = [];
const cnmtHeader = new PackagedContentMetaHeader(cnmtData);
const key = createContentMetaKey(cnmtHeader);
const extendedHeaderSize = cnmtHeader.extendedHeaderSize;
const type = cnmtHeader.type;
let extendedDataSize = 0;
// Add a `NcmContentInfo` for the `.cnmt.nca` file, since we installed it
contentInfos.push(cnmtContentInfo);
// Add content records from `.cnmt` file
const contentCount = cnmtHeader.contentCount;
for (let i = 0; i < contentCount; i++) {
const packagedContentInfoOffset = PackagedContentMetaHeader.sizeof +
extendedHeaderSize +
i * NcmPackagedContentInfo.sizeof;
const packagedContentInfo = new NcmPackagedContentInfo(cnmtData, packagedContentInfoOffset);
const contentInfo = packagedContentInfo.contentInfo;
// Don't install delta fragments. Even patches don't seem to install them.
if (contentInfo.contentType === NcmContentType.DeltaFragment) {
continue;
}
contentInfos.push(contentInfo);
ncaFilesToInstall.add(`${contentInfo.contentId}.nca`);
}
if (type === NcmContentMetaType.Patch) {
const patchMetaExtendedHeader = new NcmPatchMetaExtendedHeader(cnmtData, PackagedContentMetaHeader.sizeof);
extendedDataSize = patchMetaExtendedHeader.extendedDataSize;
}
const contentMetaData = new Uint8Array(NcmContentMetaHeader.sizeof +
extendedHeaderSize +
contentInfos.length * NcmContentInfo.sizeof +
extendedDataSize);
// write header
const contentMetaHeader = new NcmContentMetaHeader(contentMetaData);
contentMetaHeader.extendedHeaderSize = extendedHeaderSize;
contentMetaHeader.contentCount = contentInfos.length;
contentMetaHeader.contentMetaCount = 0;
contentMetaHeader.attributes = 0;
contentMetaHeader.storageId = 0;
// write extended header
contentMetaData.set(new Uint8Array(cnmtData, PackagedContentMetaHeader.sizeof, extendedHeaderSize), NcmContentMetaHeader.sizeof);
// Optionally disable the required system version field
if (type === NcmContentMetaType.Application) {
const extendedHeader = new NcmApplicationMetaExtendedHeader(contentMetaData, NcmContentMetaHeader.sizeof);
extendedHeader.requiredSystemVersion = 0;
}
else if (type === NcmContentMetaType.Patch) {
const extendedHeader = new NcmPatchMetaExtendedHeader(contentMetaData, NcmContentMetaHeader.sizeof);
extendedHeader.requiredSystemVersion = 0;
}
// write content infos
for (let i = 0; i < contentInfos.length; i++) {
const offset = NcmContentMetaHeader.sizeof +
extendedHeaderSize +
i * NcmContentInfo.sizeof;
contentMetaData.set(u8(contentInfos[i]), offset);
}
// write extended data
if (extendedDataSize > 0) {
contentMetaData.set(new Uint8Array(cnmtData, PackagedContentMetaHeader.sizeof +
extendedHeaderSize +
contentInfos.length * NcmContentInfo.sizeof, extendedDataSize), NcmContentMetaHeader.sizeof +
extendedHeaderSize +
contentInfos.length * NcmContentInfo.sizeof);
}
contentMetaDatabase.set(key, contentMetaData);
contentMetaDatabase.commit();
return key;
}
async function pushApplicationRecord(titleId, contentMetaKeys, storageId) {
console.debug(`Pushing application record for "${titleId
.toString(16)
.padStart(16, '0')}" (${contentMetaKeys.length} content meta keys)`);
// TODO: for some reason, `nsCountApplicationContentMeta()` is returning 0.
// So skip for now and rely on `listApplicationRecordContentMeta()` instead
//let existingRecordCount = 0;
//try {
// existingRecordCount = nsCountApplicationContentMeta(titleId);
//} catch (err: unknown) {
// console.log(err);
// const recordDoesNotExist = false; // TODO for code 0x410
// if (!recordDoesNotExist) {
// throw err;
// }
//}
//console.debug(`Found ${existingRecordCount} existing content meta records`);
const contentStorageRecords = new Uint8Array(NcmContentStorageRecord.sizeof * 20);
let entriesRead = 0;
try {
entriesRead = nsListApplicationRecordContentMeta(0n, titleId, contentStorageRecords);
console.debug(`Found ${entriesRead} existing content meta records`);
}
catch (err) {
//console.log('nsListApplicationRecordContentMeta', err);
}
// Add new content meta keys to the end of the array
for (let i = 0; i < contentMetaKeys.length; i++) {
const contentStorageRecord = new NcmContentStorageRecord(contentStorageRecords, (entriesRead + i) * NcmContentStorageRecord.sizeof);
contentStorageRecord.key = contentMetaKeys[i];
contentStorageRecord.storageId = storageId;
}
try {
nsDeleteApplicationRecord(titleId);
}
catch { }
nsPushApplicationRecord(titleId, NsApplicationRecordType.Installed, contentStorageRecords.slice(0, (entriesRead + contentMetaKeys.length) * NcmContentStorageRecord.sizeof));
// force flush
if (entriesRead > 0) {
nsInvalidateApplicationControlCache(titleId);
}
}
function getBaseTitleId(titleId, type) {
switch (type) {
case NcmContentMetaType.Patch:
return titleId ^ 0x800n;
case NcmContentMetaType.AddOnContent:
return (titleId ^ 0x1000n) & ~0xfffn;
default:
return titleId;
}
}
//# sourceMappingURL=index.js.map