const child_process = require('child_process'); const fs = require('fs'); const xml2js = require('xml2js'); const glob = require('glob'); const nAsync = require('async'); const path = require('path'); const axios = require('axios'); const sharp = require('sharp'); const unzipper = require('unzipper'); // NOTE: sharp will keep some files open and prevent them from being deleted sharp.cache(false); const xmlParser = new xml2js.Parser(); const xmlBuilder = new xml2js.Builder(); const runelitePath = './runelite'; const cacheProjectPath = `${runelitePath}/cache`; const cachePomPath = `${cacheProjectPath}/pom.xml`; const cacheJarOutputDir = `${cacheProjectPath}/target`; const osrsCacheDirectory = './cache/cache'; const siteItemDataPath = '../site/public/data/item_data.json'; const siteMapIconMetaPath = "../site/public/data/map_icons.json"; const siteMapLabelMetaPath = "../site/public/data/map_labels.json"; const siteItemImagesPath = '../site/public/icons/items'; const siteMapImagesPath = '../site/public/map'; const siteMapLabelsPath = '../site/public/map/labels'; const siteMapIconPath = "../site/public/map/icons/map_icons.webp"; const tileSize = 256; function exec(command, options) { console.log(command); options = options || {}; options.stdio = 'inherit'; try { child_process.execSync(command, options); } catch (err) { console.log(err); process.exit(1); } } async function retry(fn, skipLast) { const attempts = 10; for (let i = 0; i < attempts; ++i) { try { await fn(); return; } catch (ex) { await new Promise(resolve => setTimeout(resolve, 100)); if (i === (attempts - 1) && skipLast) { console.error(ex); } } } if (!skipLast) { fn(); } } async function setMainClassInCachePom(mainClass) { console.log(`Setting mainClass of ${cachePomPath} to ${mainClass}`); xmlParser.reset(); const cachePomData = fs.readFileSync(cachePomPath, 'utf8'); const cachePom = await xmlParser.parseStringPromise(cachePomData); const plugins = cachePom.project.build[0].plugins[0].plugin; const mavenAssemblyPlugin = plugins.find((plugin) => plugin.artifactId[0] === 'maven-assembly-plugin'); const configuration = mavenAssemblyPlugin.configuration[0]; configuration.archive = [{ manifest: [{ mainClass: [mainClass] }] }]; const cachePomResult = xmlBuilder.buildObject(cachePom); fs.writeFileSync(cachePomPath, cachePomResult); } function execRuneliteCache(params) { const jars = glob.sync(`${cacheJarOutputDir}/cache-*-jar-with-dependencies.jar`); let cacheJar = jars[0]; let cacheJarmtime = fs.statSync(cacheJar).mtime; for (const jar of jars) { const mtime = fs.statSync(jar).mtime; if (mtime > cacheJarmtime) { cacheJarmtime = mtime; cacheJar = jar; } } const cmd = `java -Xmx8g -jar ${cacheJar} ${params}`; exec(cmd); } async function readAllItemFiles() { const itemFiles = glob.sync(`./item-data/*.json`); const result = {}; const q = nAsync.queue((itemFile, callback) => { fs.promises.readFile(itemFile, 'utf8').then((itemFileData) => { const item = JSON.parse(itemFileData); if (isNaN(item.id)) console.log(item); result[item.id] = item; callback(); }); }, 50); for (const itemFile of itemFiles) { q.push(itemFile); } await q.drain(); return result; } function buildCacheProject() { exec(`mvn install -Dmaven.test.skip=true -f pom.xml`, { cwd: cacheProjectPath }); } async function setupRunelite() { console.log('Step: Setting up runelite'); if (!fs.existsSync(runelitePath)) { exec(`git clone "git@github.com:runelite/runelite.git"`); } exec(`git fetch origin master`, { cwd: runelitePath }); exec(`git reset --hard origin/master`, { cwd: runelitePath }); } async function dumpItemData() { console.log('\nStep: Unpacking item data from cache'); await setMainClassInCachePom('net.runelite.cache.Cache'); buildCacheProject(); execRuneliteCache(`-c ${osrsCacheDirectory} -items ./item-data`); } async function getNonAlchableItemNames() { console.log('\nStep: Fetching unalchable items from wiki'); const nonAlchableItemNames = new Set(); let cmcontinue = ''; do { const url = `https://oldschool.runescape.wiki/api.php?cmtitle=Category:Items_that_cannot_be_alchemised&action=query&list=categorymembers&format=json&cmlimit=500&cmcontinue=${cmcontinue}`; const response = await axios.get(url); const itemNames = response.data.query.categorymembers.map((member) => member.title).filter((title) => !title.startsWith('File:') && !title.startsWith('Category:')); itemNames.forEach((name) => nonAlchableItemNames.add(name)); cmcontinue = response.data?.continue?.cmcontinue || null; } while(cmcontinue); return nonAlchableItemNames; } async function buildItemDataJson() { console.log('\nStep: Build item_data.json'); const items = await readAllItemFiles(); const includedItems = {}; const allIncludedItemIds = new Set(); for (const [itemId, item] of Object.entries(items)) { if (item.name && item.name.trim().toLowerCase() !== 'null') { const includedItem = { name: item.name, highalch: Math.floor(item.cost * 0.6) }; const stackedList = []; if (item.countCo && item.countObj && item.countCo.length > 0 && item.countObj.length > 0) { for (let i = 0; i < item.countCo.length; ++i) { const stackBreakPoint = item.countCo[i]; const stackedItemId = item.countObj[i]; if (stackBreakPoint > 0 && stackedItemId === 0) { console.log(`${itemId}: Item has a stack breakpoint without an associated item id for that stack.`); } else if (stackBreakPoint > 0 && stackedItemId > 0) { allIncludedItemIds.add(stackedItemId); stackedList.push([stackBreakPoint, stackedItemId]); } } if (stackedList.length > 0) { includedItem.stacks = stackedList; } } allIncludedItemIds.add(item.id); includedItems[itemId] = includedItem; } } const nonAlchableItemNames = await getNonAlchableItemNames(); let itemsMadeNonAlchable = 0; for (const item of Object.values(includedItems)) { const itemName = item.name; if (nonAlchableItemNames.has(itemName)) { // NOTE: High alch value = 0 just means unalchable in the context of this program item.highalch = 0; itemsMadeNonAlchable++; } // NOTE: The wiki data does not list every variant of an item such as 'Abyssal lantern (yew logs)' // which is also not alchable. So this step is to handle that case by searching for the non variant item. if (itemName.trim().endsWith(')') && itemName.indexOf('(') !== -1) { const nonVariantItemName = itemName.substring(0, itemName.indexOf('(')).trim(); if (nonAlchableItemNames.has(nonVariantItemName)) { item.highalch = 0; itemsMadeNonAlchable++; } } } console.log(`${itemsMadeNonAlchable} items were updated to be unalchable`); fs.writeFileSync('./item_data.json', JSON.stringify(includedItems)); return allIncludedItemIds; } async function dumpItemImages(allIncludedItemIds) { console.log('\nStep: Extract item model images'); console.log(`Generating images for ${allIncludedItemIds.size} items`); fs.writeFileSync('items_need_images.csv', Array.from(allIncludedItemIds.values()).join(',')); const imageDumperDriver = fs.readFileSync('./Cache.java', 'utf8'); fs.writeFileSync(`${cacheProjectPath}/src/main/java/net/runelite/cache/Cache.java`, imageDumperDriver); const itemSpriteFactory = fs.readFileSync('./ItemSpriteFactory.java', 'utf8'); fs.writeFileSync(`${cacheProjectPath}/src/main/java/net/runelite/cache/item/ItemSpriteFactory.java`, itemSpriteFactory); buildCacheProject(); execRuneliteCache(`-c ${osrsCacheDirectory} -ids ./items_need_images.csv -output ./item-images`); const itemImages = glob.sync(`./item-images/*.png`); let p = []; for (const itemImage of itemImages) { p.push(new Promise(async (resolve) => { const itemImageData = await sharp(itemImage).webp({ lossless: true }).toBuffer(); fs.unlinkSync(itemImage); await sharp(itemImageData).webp({ lossless: true, effort: 6 }).toFile(itemImage.replace(".png", ".webp")).then(resolve); })); } await Promise.all(p); } async function convertXteasToRuneliteFormat() { const xteas = JSON.parse(fs.readFileSync(`${osrsCacheDirectory}/../xteas.json`, 'utf8')); let result = xteas.map((region) => ({ region: region.mapsquare, keys: region.key })); const location = `${osrsCacheDirectory}/../xteas-runelite.json`; fs.writeFileSync(location, JSON.stringify(result)); return location; } async function dumpMapData(xteasLocation) { console.log('\nStep: Dumping map data'); const mapImageDumper = fs.readFileSync('./MapImageDumper.java', 'utf8'); fs.writeFileSync(`${cacheProjectPath}/src/main/java/net/runelite/cache/MapImageDumper.java`, mapImageDumper); await setMainClassInCachePom('net.runelite.cache.MapImageDumper'); buildCacheProject(); execRuneliteCache(`--cachedir ${osrsCacheDirectory} --xteapath ${xteasLocation} --outputdir ./map-data`); } async function dumpMapLabels() { console.log('\nStep: Dumping map labels'); const mapLabelDumper = fs.readFileSync('./MapLabelDumper.java', 'utf8'); fs.writeFileSync(`${cacheProjectPath}/src/main/java/net/runelite/cache/MapLabelDumper.java`, mapLabelDumper); await setMainClassInCachePom('net.runelite.cache.MapLabelDumper'); buildCacheProject(); execRuneliteCache(`--cachedir ${osrsCacheDirectory} --outputdir ./map-data/labels`); const mapLabels = glob.sync("./map-data/labels/*.png"); let p = []; for (const mapLabel of mapLabels) { p.push(new Promise(async (resolve) => { const mapLabelImageData = await sharp(mapLabel).webp({ lossless: true }).toBuffer(); fs.unlinkSync(mapLabel); await sharp(mapLabelImageData).webp({ lossless: true, effort: 6 }).toFile(mapLabel.replace(".png", ".webp")).then(resolve); })); } await Promise.all(p); } async function dumpCollectionLog() { console.log('\nStep: Dumping collection log'); const collectionLogDumper = fs.readFileSync('./CollectionLogDumper.java', 'utf8'); fs.writeFileSync(`${cacheProjectPath}/src/main/java/net/runelite/cache/CollectionLogDumper.java`, collectionLogDumper); await setMainClassInCachePom('net.runelite.cache.CollectionLogDumper'); buildCacheProject(); execRuneliteCache(`--cachedir ${osrsCacheDirectory} --outputdir ../server`); } async function tilePlane(plane) { await retry(() => fs.rmSync('./output_files', { recursive: true, force: true })); const planeImage = sharp(`./map-data/img-${plane}.png`, { limitInputPixels: false }).flip(); await planeImage.webp({ lossless: true }).tile({ size: tileSize, depth: "one", background: { r: 0, g: 0, b: 0, alpha: 0 }, skipBlanks: 0 }).toFile('output.dz'); } async function outputTileImage(s, plane, x, y) { return s.flatten({ background: '#000000' }) .webp({ lossless: true, alphaQuality: 0, effort: 6 }) .toFile(`./map-data/tiles/${plane}_${x}_${y}.webp`); } async function finalizePlaneTiles(plane, previousTiles) { const tileImages = glob.sync('./output_files/0/*.webp'); for (const tileImage of tileImages) { const filename = path.basename(tileImage, '.webp'); const [x, y] = filename.split('_').map((coord) => parseInt(coord, 10)); const finalX = x + (4608 / tileSize); const finalY = y + (4864 / tileSize); let s; if (plane > 0) { const backgroundPath = `./map-data/tiles/${plane-1}_${finalX}_${finalY}.webp`; const backgroundExists = fs.existsSync(backgroundPath); if (backgroundExists) { const tile = await sharp(tileImage).flip().webp({ lossless: true }).toBuffer(); const background = await sharp(backgroundPath).linear(0.5).webp({ lossless: true }).toBuffer(); s = sharp(background) .composite([ { input: tile } ]); } } if (!s) { s = sharp(tileImage).flip(); } previousTiles.add(`${plane}_${finalX}_${finalY}`); await outputTileImage(s, plane, finalX, finalY); } // NOTE: This is just so the plane will have a darker version of the tile below it // even if the plane does not have its own image for a tile. if (plane > 0) { const belowTiles = [...previousTiles].filter(x => x.startsWith(plane - 1)); for (const belowTile of belowTiles) { const [belowPlane, x, y] = belowTile.split('_'); const lookup = `${plane}_${x}_${y}`; if (!previousTiles.has(lookup)) { const outputPath = `./map-data/tiles/${plane}_${x}_${y}.webp`; if (fs.existsSync(outputPath) === true) { throw new Error(`Filling tile ${outputPath} but it already exists!`); } const s = sharp(`./map-data/tiles/${belowTile}.webp`).linear(0.5); previousTiles.add(lookup); await outputTileImage(s, plane, x, y); } } } } async function generateMapTiles() { console.log('\nStep: Generate map tiles'); fs.rmSync('./map-data/tiles', { recursive: true, force: true }); fs.mkdirSync('./map-data/tiles'); const previousTiles = new Set(); const planes = 4; for (let i = 0; i < planes; ++i) { console.log(`Tiling map plane ${i + 1}/${planes}`); await tilePlane(i); console.log(`Finalizing map plane ${i + 1}/${planes}`); await finalizePlaneTiles(i, previousTiles); } } async function moveFiles(globSource, destination) { const files = glob.sync(globSource); for (file of files) { const base = path.parse(file).base; if (base) { await retry(() => fs.renameSync(file, `${destination}/${base}`), true); } } } async function moveResults() { console.log('\nStep: Moving results to site'); await retry(() => fs.renameSync('./item_data.json', siteItemDataPath), true); await moveFiles('./item-images/*.webp', siteItemImagesPath); await moveFiles("./map-data/tiles/*.webp", siteMapImagesPath); await moveFiles("./map-data/labels/*.webp", siteMapLabelsPath); // Create a tile sheet of the map icons const mapIcons = glob.sync("./map-data/icons/*.png"); let mapIconsCompositeOpts = []; const iconIdToSpriteMapIndex = {}; for (let i = 0; i < mapIcons.length; ++i) { mapIconsCompositeOpts.push({ input: mapIcons[i], left: 15 * i, top: 0 }); iconIdToSpriteMapIndex[path.basename(mapIcons[i], '.png')] = i; } await sharp({ create: { width: 15 * mapIcons.length, height: 15, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } } }).composite(mapIconsCompositeOpts).webp({ lossless: true, effort: 6 }).toFile(siteMapIconPath); // Convert the output of the map-icons locations to be keyed by the X an Y of the regions // that they are in. This is done so that the canvas map component can quickly lookup // all of the icons in each of the regions that are being shown. const mapIconsMeta = JSON.parse(fs.readFileSync("./map-data/icons/map-icons.json", 'utf8')); const locationByRegion = {}; for (const [iconId, coordinates] of Object.entries(mapIconsMeta)) { for (let i = 0; i < coordinates.length; i += 2) { const x = coordinates[i] + 128; const y = coordinates[i + 1] + 1; const regionX = Math.floor(x / 64); const regionY = Math.floor(y / 64); const spriteMapIndex = iconIdToSpriteMapIndex[iconId]; if (spriteMapIndex === undefined) { throw new Error("Could not find sprite map index for map icon: " + iconId); } locationByRegion[regionX] = locationByRegion[regionX] || {}; locationByRegion[regionX][regionY] = locationByRegion[regionX][regionY] || {}; locationByRegion[regionX][regionY][spriteMapIndex] = locationByRegion[regionX][regionY][spriteMapIndex] || []; locationByRegion[regionX][regionY][spriteMapIndex].push(x, y); } } fs.writeFileSync(siteMapIconMetaPath, JSON.stringify(locationByRegion)); // Do the same for map labels const mapLabelsMeta = JSON.parse(fs.readFileSync("./map-data/labels/map-labels.json", 'utf8')); const labelByRegion = {}; for (let i = 0; i < mapLabelsMeta.length; ++i) { const coordinates = mapLabelsMeta[i]; const x = coordinates[0] + 128; const y = coordinates[1] + 1; const z = coordinates[2]; const regionX = Math.floor(x / 64); const regionY = Math.floor(y / 64); labelByRegion[regionX] = labelByRegion[regionX] || {}; labelByRegion[regionX][regionY] = labelByRegion[regionX][regionY] || {}; labelByRegion[regionX][regionY][z] = labelByRegion[regionX][regionY][z] || []; labelByRegion[regionX][regionY][z].push(x, y, i); } fs.writeFileSync(siteMapLabelMetaPath, JSON.stringify(labelByRegion)); } async function getLatestGameCache() { if (!fs.existsSync('./cache')) { fs.mkdirSync('./cache'); } const caches = (await axios.get('https://archive.openrs2.org/caches.json')).data; const latestOSRSCache = caches.filter((cache) => { return cache.scope === 'runescape' && cache.game === 'oldschool' && cache.environment === 'live' && !!cache.timestamp; }).sort((a, b) => (new Date(b.timestamp)) - (new Date(a.timestamp)))[0]; console.log(latestOSRSCache); const pctValidArchives = latestOSRSCache.valid_indexes / latestOSRSCache.indexes; if (pctValidArchives < 1) { throw new Error(`valid_indexes was less than indexes valid_indexes=${latestOSRSCache.valid_indexes} indexes=${latestOSRSCache.indexes} pctValidArchives=${pctValidArchives}`); } const pctValidGroups = latestOSRSCache.valid_groups / latestOSRSCache.groups; if (pctValidGroups < 1) { throw new Error(`valid_groups was less than groups valid_groups=${latestOSRSCache.valid_groups} groups=${latestOSRSCache.groups} pctValidGroups=${pctValidGroups}`); } const pctValidKeys = latestOSRSCache.valid_keys / latestOSRSCache.keys; if (pctValidKeys < 0.97) { throw new Error(`pctValidKeys was less that 97% valid_keys=${latestOSRSCache.valid_keys} keys=${latestOSRSCache.keys} pctValidKeys=${pctValidKeys}`); } const cacheFilesResponse = await axios.get(`https://archive.openrs2.org/caches/${latestOSRSCache.scope}/${latestOSRSCache.id}/disk.zip`, { responseType: 'arraybuffer' }); const cacheFiles = await unzipper.Open.buffer(cacheFilesResponse.data); await cacheFiles.extract({ path: './cache' }); const xteas = (await axios.get(`https://archive.openrs2.org/caches/${latestOSRSCache.scope}/${latestOSRSCache.id}/keys.json`)).data; fs.writeFileSync('./cache/xteas.json', JSON.stringify(xteas)); } (async () => { await getLatestGameCache(); await setupRunelite(); await dumpItemData(); const allIncludedItemIds = await buildItemDataJson(); await dumpItemImages(allIncludedItemIds); const xteasLocation = await convertXteasToRuneliteFormat(); await dumpMapData(xteasLocation); await generateMapTiles(); await dumpMapLabels(); await dumpCollectionLog(); await moveResults(); })();