🎨 Fix typos
This commit is contained in:
parent
14e73367c6
commit
94047006ec
8 changed files with 62 additions and 60 deletions
60
README.md
60
README.md
|
@ -30,6 +30,37 @@ SSN uses the following terms; make sure you are familiar with them since we use
|
||||||
- __```environment```__: An environment consists of multiple products. For example, the ```live``` environment has products like ```assets_swtor_main``` while the ```pts``` environment has ```assets_swtor_test_main```.
|
- __```environment```__: An environment consists of multiple products. For example, the ```live``` environment has products like ```assets_swtor_main``` while the ```pts``` environment has ```assets_swtor_test_main```.
|
||||||
- __```manifest```__: Each product has a manifest, a single file that lists all releases and all patches, and specifies which release is the current release (that the launcher must install).
|
- __```manifest```__: Each product has a manifest, a single file that lists all releases and all patches, and specifies which release is the current release (that the launcher must install).
|
||||||
|
|
||||||
|
# SSN file format and encryption
|
||||||
|
|
||||||
|
All files used by SSN are password-protected .zip files with a .exe header. The files are stored in the .zip section, while the .exe header (and the signatures at the end) are used to verify the integrity of the file. We are not interested in checking the integrity, so we only look at the .zip file. Check the [.zip specification](https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT) for more information on the .zip format.
|
||||||
|
|
||||||
|
For each file, the extra field contains the following information:
|
||||||
|
|
||||||
|
- For password protected files, the password needed to decrypt the file. The password itself is encoded and even if you have decoded it, it is usually in binary. Most .zip software only accepts textual passwords, so I recommend writing a custom .zip reader.
|
||||||
|
- Whether this file is stored in full, or whether only the differences are stored in vcdiff/xdelta3 encoding. Also flags whether a file was deleted, and it includes the hashes of both the old and the new file.
|
||||||
|
|
||||||
|
## Manifests (`.patchmanifest`)
|
||||||
|
|
||||||
|
A .patchmanifest SSN file contains a single file `manifest.xml` which includes a list of releases and patches, and specifies which manifest is the current one.
|
||||||
|
|
||||||
|
## Patches (`.solidpkg`)
|
||||||
|
|
||||||
|
A .solidpkg file contains a single file `metafile.solid`, which is in Bencode format (similar to .torrent files) and includes the names, sizes and hashes of all files with the patch data (.zip, .z01, .z02 etc.).
|
||||||
|
|
||||||
|
## Version files (`.version`)
|
||||||
|
|
||||||
|
Once a patch is installed, you can look into the .version file to figure out which release it is, and use the hashes to verify that the installation is correct.
|
||||||
|
|
||||||
|
# File download and CDN
|
||||||
|
|
||||||
|
Manifests are hosted on manifest.swtor.com, while patches are hosted on cdn-patch.swtor.com. They can only be downloaded via HTTP (port 80) but the files are integrity-checked via their included signatures.
|
||||||
|
|
||||||
|
Manifests can be downloaded as follows: `http://manifest.swtor.com/patch/${product}.patchmanifest`
|
||||||
|
|
||||||
|
Then knowing which patch you need, you can get the patch as follows: `http://cdn-patch.swtor.com/patch/${product}/${product}_${from}to${to}.solidpkg`
|
||||||
|
|
||||||
|
# Appendix
|
||||||
|
|
||||||
## Products used by SWTOR
|
## Products used by SWTOR
|
||||||
|
|
||||||
SWTOR uses the following products:
|
SWTOR uses the following products:
|
||||||
|
@ -71,32 +102,3 @@ The following products are deprecated and no longer used.
|
||||||
- ```retailclient_cstraining```: No longer used.
|
- ```retailclient_cstraining```: No longer used.
|
||||||
- ```retailclient_liveeptest```: No longer used.
|
- ```retailclient_liveeptest```: No longer used.
|
||||||
- ```retailclient_squadron157```: The client used during the closed beta by the Squadron157 testing group to play the game.
|
- ```retailclient_squadron157```: The client used during the closed beta by the Squadron157 testing group to play the game.
|
||||||
|
|
||||||
# SSN file format and encryption
|
|
||||||
|
|
||||||
All files used by SSN are password-protected .zip files with a .exe header. The files are stored in the .zip section, while the .exe header (and the signatures at the end) are used to verify the integrity of the file. We are not interested in checking the integrity, so we only look at the .zip file. Check the [.zip specification](https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT) for more information on the .zip format.
|
|
||||||
|
|
||||||
For each file, the extra field contains the following information:
|
|
||||||
|
|
||||||
- For password protected files, the password needed to decrypt the file. The password itself is encoded and even if you have decoded it, it is usually in binary. Most .zip software only accepts textual passwords, so I recommend writing a custom .zip reader.
|
|
||||||
- Whether this file is stored in full, or whether only the differences are stored in vcdiff/xdelta3 encoding. Also flags whether a file was deleted, and it includes the hashes of both the old and the new file.
|
|
||||||
|
|
||||||
## Manifests (`.patchmanifest`)
|
|
||||||
|
|
||||||
A .patchmanifest SSN file contains a single file `manifest.xml` which includes a list of releases and patches, and specifies which manifest is the current one.
|
|
||||||
|
|
||||||
## Patches (`.solidpkg`)
|
|
||||||
|
|
||||||
A .solidpkg file contains a single file `metafile.solid`, which is in Bencode format (similar to .torrent files) and includes the names, sizes and hashes of all files with the patch data (.zip, .z01, .z02 etc.).
|
|
||||||
|
|
||||||
## Version files (`.version`)
|
|
||||||
|
|
||||||
Once a patch is installed, you can look into the .version file to figure out which release it is, and use the hashes to verify that the installation is correct.
|
|
||||||
|
|
||||||
# File download and CDN
|
|
||||||
|
|
||||||
Manifests are hosted on manifest.swtor.com, while patches are hosted on cdn-patch.swtor.com. They can only be downloaded via HTTP (port 80) but the files are integrity-checked via their included signatures.
|
|
||||||
|
|
||||||
Manifests can be downloaded as follows: `http://manifest.swtor.com/patch/${product}.patchmanifest`
|
|
||||||
|
|
||||||
Then knowing which patch you need, you can get the patch as follows: `http://cdn-patch.swtor.com/patch/${product}/${product}_${from}to${to}.solidpkg`
|
|
||||||
|
|
|
@ -31,7 +31,7 @@ import * as dns from 'dns';
|
||||||
import { IDnsResult } from '../../interfaces/IDnsResult';
|
import { IDnsResult } from '../../interfaces/IDnsResult';
|
||||||
|
|
||||||
//TODO: send e-mail with the error
|
//TODO: send e-mail with the error
|
||||||
const assert = (cond: boolean) => { if (!cond) { console.warn('Assert failed'); } };
|
const assert = (condition: boolean) => { if (!condition) { console.warn('Assert failed'); } };
|
||||||
|
|
||||||
/** Looks up the given domain and returns a list of IP addresses, along with their time-to-live */
|
/** Looks up the given domain and returns a list of IP addresses, along with their time-to-live */
|
||||||
async function resolveDns(domain: string): Promise<IDnsResult[]> {
|
async function resolveDns(domain: string): Promise<IDnsResult[]> {
|
||||||
|
|
|
@ -15,7 +15,7 @@ async function heartbeatDns(domain: string) {
|
||||||
//Get list of current patch servers
|
//Get list of current patch servers
|
||||||
const dnsResults = await resolveDns(domain);
|
const dnsResults = await resolveDns(domain);
|
||||||
|
|
||||||
//Remeber time when response came in
|
//Remember time when response came in
|
||||||
const now = Date.now() - startTime;
|
const now = Date.now() - startTime;
|
||||||
|
|
||||||
//Schedule next check based on time-to-live, but never longer than 1 minute
|
//Schedule next check based on time-to-live, but never longer than 1 minute
|
||||||
|
|
|
@ -25,7 +25,7 @@ interface ISsnFileEntry {
|
||||||
/** Uncompressed size */
|
/** Uncompressed size */
|
||||||
size: number;
|
size: number;
|
||||||
/** Compressed size */
|
/** Compressed size */
|
||||||
comprSize: number;
|
compressedSize: number;
|
||||||
/** Decryption keys needed to decrypt the file */
|
/** Decryption keys needed to decrypt the file */
|
||||||
decryptionKeys: [number, number, number] | undefined;
|
decryptionKeys: [number, number, number] | undefined;
|
||||||
/** Number of the disk where the file is stored (0=.z01, 1=.z02 etc.) */
|
/** Number of the disk where the file is stored (0=.z01, 1=.z02 etc.) */
|
||||||
|
|
|
@ -22,11 +22,11 @@ export default async function extractFile(file: ISsnFileEntry, dvArray: DataView
|
||||||
byteReader.seek(localFilenameSize + localExtraSize);
|
byteReader.seek(localFilenameSize + localExtraSize);
|
||||||
|
|
||||||
//Extract actual file contents
|
//Extract actual file contents
|
||||||
let dvFinal = byteReader.extractDv(file.comprSize);
|
let dvFinal = byteReader.extractDv(file.compressedSize);
|
||||||
|
|
||||||
//Decrypt file if necessary
|
//Decrypt file if necessary
|
||||||
if (file.decryptionKeys !== undefined) {
|
if (file.decryptionKeys !== undefined) {
|
||||||
dvFinal = decryptFile(dvFinal, file.comprSize, file.decryptionKeys);
|
dvFinal = decryptFile(dvFinal, file.compressedSize, file.decryptionKeys);
|
||||||
}
|
}
|
||||||
|
|
||||||
//Decompress file
|
//Decompress file
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { Product } from '../interfaces/ISettings';
|
||||||
import verifyProductName from '../ssn/verify/verifyProductName';
|
import verifyProductName from '../ssn/verify/verifyProductName';
|
||||||
|
|
||||||
/** For the given release in the given product, returns from which releases we can patch to this release. */
|
/** For the given release in the given product, returns from which releases we can patch to this release. */
|
||||||
function getFroms({ product, to: releaseTo}: {product: Product, to: number}) {
|
function getFromList({ product, to: releaseTo}: {product: Product, to: number}) {
|
||||||
if (releaseTo < 0) {
|
if (releaseTo < 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
@ -11,24 +11,24 @@ function getFroms({ product, to: releaseTo}: {product: Product, to: number}) {
|
||||||
if (product.startsWith('patcher')) {
|
if (product.startsWith('patcher')) {
|
||||||
return [-1];
|
return [-1];
|
||||||
} else {
|
} else {
|
||||||
const froms: number[] = [];
|
const fromList: number[] = [];
|
||||||
|
|
||||||
//Always X-1toX
|
//Always X-1toX
|
||||||
froms.push(releaseTo - 1);
|
fromList.push(releaseTo - 1);
|
||||||
|
|
||||||
//Also 0toX, unless X is 0. And no need to add 0to1 a second time.
|
//Also 0toX, unless X is 0. And no need to add 0to1 a second time.
|
||||||
if (releaseTo >= 2) { froms.push(0); }
|
if (releaseTo >= 2) { fromList.push(0); }
|
||||||
|
|
||||||
if ((releaseTo % 5) === 0) {
|
if ((releaseTo % 5) === 0) {
|
||||||
//Also X-5toX if X % 5
|
//Also X-5toX if X % 5
|
||||||
if (releaseTo >= 10) { froms.push(releaseTo - 5); }
|
if (releaseTo >= 10) { fromList.push(releaseTo - 5); }
|
||||||
//Also X-20toX if X % 5
|
//Also X-20toX if X % 5
|
||||||
if (releaseTo >= 25) { froms.push(releaseTo - 20); }
|
if (releaseTo >= 25) { fromList.push(releaseTo - 20); }
|
||||||
//Also downgrade from the following four releases
|
//Also downgrade from the following four releases
|
||||||
froms.push(releaseTo + 1);
|
fromList.push(releaseTo + 1);
|
||||||
froms.push(releaseTo + 2);
|
fromList.push(releaseTo + 2);
|
||||||
froms.push(releaseTo + 3);
|
fromList.push(releaseTo + 3);
|
||||||
froms.push(releaseTo + 4);
|
fromList.push(releaseTo + 4);
|
||||||
} else { //For some of the older releases, an update from _5 or _0 is possible
|
} else { //For some of the older releases, an update from _5 or _0 is possible
|
||||||
/*
|
/*
|
||||||
e.g. in asset_swtor_main:
|
e.g. in asset_swtor_main:
|
||||||
|
@ -40,10 +40,10 @@ function getFroms({ product, to: releaseTo}: {product: Product, to: number}) {
|
||||||
30to32, 30to33, 30to34,
|
30to32, 30to33, 30to34,
|
||||||
35to37, etc. , 85to87
|
35to37, etc. , 85to87
|
||||||
*/
|
*/
|
||||||
if (releaseTo >= 7 && (releaseTo % 5) > 1) { froms.push(releaseTo - (releaseTo % 5)); }
|
if (releaseTo >= 7 && (releaseTo % 5) > 1) { fromList.push(releaseTo - (releaseTo % 5)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
return froms;
|
return fromList;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -65,21 +65,21 @@ export default function findReleasePath({ product, from, to}: {product: Product,
|
||||||
throw new Error('Cannot patch backwards; to must be greater than from.');
|
throw new Error('Cannot patch backwards; to must be greater than from.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const froms = getFroms({ product, to });
|
const fromList = getFromList({ product, to });
|
||||||
|
|
||||||
//If we can patch, return immediately
|
//If we can patch, return immediately
|
||||||
if (froms.includes(from)) {
|
if (fromList.includes(from)) {
|
||||||
return [[from, to]];
|
return [[from, to]];
|
||||||
}
|
}
|
||||||
|
|
||||||
//Otherwise, check all froms recursively, by checking interim releases
|
//Otherwise, check all from values recursively, by checking interim releases
|
||||||
const smallerFroms = froms.filter((num) => num > from);
|
const smallerFromList = fromList.filter((num) => num > from);
|
||||||
//Always prefer shortest release paths (e.g. 1->3 vs. 1->2->3) by ensuring we check smallest froms first
|
//Always prefer shortest release paths (e.g. 1->3 vs. 1->2->3) by ensuring we check smallest from values first
|
||||||
smallerFroms.sort();
|
smallerFromList.sort();
|
||||||
|
|
||||||
for (let i = 0, il = smallerFroms.length; i < il; i += 1) {
|
for (let i = 0, il = smallerFromList.length; i < il; i += 1) {
|
||||||
const interim = smallerFroms[i];
|
const interim = smallerFromList[i];
|
||||||
//TODO: This sometimes causes a "Maximum call stack size exceeded" error, e.g. for `{ product: '*', from: 1, to: 11 }`
|
//FIXME: This sometimes causes a "Maximum call stack size exceeded" error, e.g. for `{ product: '*', from: 1, to: 11 }`
|
||||||
const releasePath = findReleasePath({ product, from, to: interim} );
|
const releasePath = findReleasePath({ product, from, to: interim} );
|
||||||
if (releasePath.length > 0) {
|
if (releasePath.length > 0) {
|
||||||
return [...releasePath, [interim, to]];
|
return [...releasePath, [interim, to]];
|
||||||
|
|
|
@ -90,9 +90,9 @@ export default function readSsnFile(buffer: ArrayBuffer): ISsnFileEntry[] {
|
||||||
/** crc-32 */
|
/** crc-32 */
|
||||||
const fileCrc = dv.getUint32(pos, true); pos += 4;
|
const fileCrc = dv.getUint32(pos, true); pos += 4;
|
||||||
/** compressed size */
|
/** compressed size */
|
||||||
const comprSize = dv.getUint32(pos, true); pos += 4;
|
const compressedSize = dv.getUint32(pos, true); pos += 4;
|
||||||
/** decompressed size */
|
/** decompressed size */
|
||||||
const decomprSize = dv.getUint32(pos, true); pos += 4;
|
const decompressedSize = dv.getUint32(pos, true); pos += 4;
|
||||||
/** file name length */
|
/** file name length */
|
||||||
const fileNameLength = dv.getUint16(pos, true); pos += 2;
|
const fileNameLength = dv.getUint16(pos, true); pos += 2;
|
||||||
/** extra field length */
|
/** extra field length */
|
||||||
|
@ -146,7 +146,7 @@ export default function readSsnFile(buffer: ArrayBuffer): ISsnFileEntry[] {
|
||||||
// ------- CREATE ENTRY ---------
|
// ------- CREATE ENTRY ---------
|
||||||
|
|
||||||
const fileEntry: ISsnFileEntry = {
|
const fileEntry: ISsnFileEntry = {
|
||||||
comprSize,
|
compressedSize,
|
||||||
crc: fileCrc,
|
crc: fileCrc,
|
||||||
decryptionKeys: undefined,
|
decryptionKeys: undefined,
|
||||||
diffDestLength,
|
diffDestLength,
|
||||||
|
@ -158,7 +158,7 @@ export default function readSsnFile(buffer: ArrayBuffer): ISsnFileEntry[] {
|
||||||
offset: (centralDirOffset > 0) ? //If files are included in this archive, the centralDirOffset will not start from the beginning
|
offset: (centralDirOffset > 0) ? //If files are included in this archive, the centralDirOffset will not start from the beginning
|
||||||
posCentralDirStart - centralDirOffset + relOffset : //if file is in this archive
|
posCentralDirStart - centralDirOffset + relOffset : //if file is in this archive
|
||||||
relOffset, //if we need to look in a disk (e.g. .z01 for this file)
|
relOffset, //if we need to look in a disk (e.g. .z01 for this file)
|
||||||
size: decomprSize,
|
size: decompressedSize,
|
||||||
};
|
};
|
||||||
|
|
||||||
//If file is encrypted
|
//If file is encrypted
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { Product } from '../../interfaces/ISettings';
|
||||||
export default function verifyPatchmanifest(manifestFile: xmlJs.Element, product: Product): any {
|
export default function verifyPatchmanifest(manifestFile: xmlJs.Element, product: Product): any {
|
||||||
//<?xml version="1.0" encoding="utf-8"?>
|
//<?xml version="1.0" encoding="utf-8"?>
|
||||||
if (manifestFile.declaration === undefined || manifestFile.declaration.attributes === undefined || Object.keys(manifestFile.declaration.attributes).length !== 2 || manifestFile.declaration.attributes.version !== '1.0' || manifestFile.declaration.attributes.encoding !== 'utf-8') {
|
if (manifestFile.declaration === undefined || manifestFile.declaration.attributes === undefined || Object.keys(manifestFile.declaration.attributes).length !== 2 || manifestFile.declaration.attributes.version !== '1.0' || manifestFile.declaration.attributes.encoding !== 'utf-8') {
|
||||||
throw new Error('Expected declration with version 1.0 and utf-8 encoding.');
|
throw new Error('Expected declaration with version 1.0 and utf-8 encoding.');
|
||||||
}
|
}
|
||||||
|
|
||||||
//<PatchManifest xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
|
//<PatchManifest xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
|
||||||
|
|
Loading…
Reference in a new issue