├── .editorconfig ├── .gitattributes ├── .github └── workflows │ └── publish-on-tag.yml ├── .gitignore ├── .nvmrc ├── LICENSE-MIT.txt ├── README.md ├── bin └── cli.mjs ├── package.json ├── src ├── array-to-minimap-marker.mjs ├── colors.mjs ├── from-minimap.mjs ├── generate-bounds-from-minimap.mjs ├── glob-promise.mjs ├── handle-parallel.mjs ├── icons.mjs ├── minimap-id-to-absolute-xyz.mjs ├── pixel-data-to-map.mjs ├── pixel-data-to-path.mjs ├── png-to-buffer.mjs ├── save-canvas-to-png.mjs ├── sort-markers.mjs ├── to-minimap.mjs └── write-json.mjs └── test ├── data-union-base └── markers.json ├── data-union-new └── .gitkeep ├── data-union └── markers.json ├── extra └── achievements │ ├── Minimap_Color_32000_30976_5.png │ ├── Minimap_Color_32256_32256_7.png │ ├── Minimap_WaypointCost_32000_31744_5.png │ └── markers.json ├── minimap-extra-new └── .gitkeep ├── minimap-extra ├── Minimap_Color_32000_30976_15.png ├── Minimap_Color_32000_30976_5.png ├── Minimap_Color_32000_31232_8.png ├── Minimap_Color_32000_31744_5.png ├── Minimap_WaypointCost_32000_30976_15.png ├── Minimap_WaypointCost_32000_31232_8.png ├── Minimap_WaypointCost_32000_31744_5.png └── minimapmarkers.bin ├── minimap-grid-new └── .gitkeep ├── minimap-grid ├── Minimap_Color_32000_30976_15.png ├── Minimap_Color_32000_30976_5.png ├── Minimap_Color_32000_31232_8.png ├── Minimap_Color_32000_31744_5.png ├── Minimap_WaypointCost_32000_30976_15.png ├── Minimap_WaypointCost_32000_31232_8.png ├── Minimap_WaypointCost_32000_31744_5.png └── minimapmarkers.bin ├── minimap-new └── .gitkeep ├── minimap ├── Minimap_Color_32000_30976_15.png ├── Minimap_Color_32000_30976_5.png ├── Minimap_Color_32000_31232_8.png ├── Minimap_Color_32000_31744_5.png ├── Minimap_WaypointCost_32000_30976_15.png ├── Minimap_WaypointCost_32000_31232_8.png ├── Minimap_WaypointCost_32000_31744_5.png └── minimapmarkers.bin ├── test.mjs └── util.mjs /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = tab 6 | end_of_line = lf 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | 10 | [{README.md,.travis.yml}] 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Automatically normalize line endings for all text-based files. 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/workflows/publish-on-tag.yml: -------------------------------------------------------------------------------- 1 | name: publish-on-tag 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v2 14 | - name: Set up Node.js 15 | uses: actions/setup-node@v3 16 | with: 17 | node-version-file: '.nvmrc' 18 | - name: Install dependencies 19 | run: npm install 20 | - name: Publish 21 | env: 22 | NPM_TOKEN: ${{secrets.NPM_TOKEN}} 23 | run: | 24 | npm config set registry 'https://wombat-dressing-room.appspot.com/' 25 | npm config set '//wombat-dressing-room.appspot.com/:_authToken' '${NPM_TOKEN}' 26 | npm publish 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | data 2 | test/data 3 | test/data-without-markers 4 | test/minimap*-new/* 5 | !test/minimap*-new/.gitkeep 6 | test/data*-new/* 7 | !test/data*-new/.gitkeep 8 | 9 | # 0x flamecharts 10 | *.0x 11 | 12 | # Installed npm modules 13 | node_modules 14 | package-lock.json 15 | 16 | # Folder view configuration files 17 | .DS_Store 18 | Desktop.ini 19 | 20 | # Thumbnail cache files 21 | ._* 22 | Thumbs.db 23 | 24 | # Files that might appear on external disks 25 | .Spotlight-V100 26 | .Trashes 27 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 14 2 | -------------------------------------------------------------------------------- /LICENSE-MIT.txt: -------------------------------------------------------------------------------- 1 | Copyright Mathias Bynens 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `tibia-maps` CLI [![tibia-maps on npm](https://img.shields.io/npm/v/tibia-maps)](https://www.npmjs.com/package/tibia-maps) 2 | 3 | `tibia-maps` is a command-line utility to convert between binary [Tibia](https://www.tibia.com/) maps and [human-readable forms of the map data](https://github.com/tibiamaps/tibia-map-data). 4 | 5 | ## Installation 6 | 7 | **Note:** Use the [expected](https://github.com/tibiamaps/tibia-maps-script/blob/main/.nvmrc) Node.js version! 8 | 9 | ```sh 10 | npm install -g tibia-maps 11 | ``` 12 | 13 | If you’re on macOS and you get [an error about `xcb-shm`](https://github.com/Automattic/node-canvas/pull/541), try this instead: 14 | 15 | ```sh 16 | export PKG_CONFIG_PATH="${PKG_CONFIG_PATH}:/opt/X11/lib/pkgconfig"; npm install -g tibia-maps 17 | ``` 18 | 19 | ## Usage 20 | 21 | ### `minimap/*` → `data/*` 22 | 23 | To generate PNGs for the maps + pathfinding visualization and JSON for the marker data based on the map files in the `minimap` directory, run: 24 | 25 | ```sh 26 | tibia-maps --from-minimap=./minimap --output-dir=./data 27 | ``` 28 | 29 | The output is saved in the `data` directory. 30 | 31 | ### `data/*` → `minimap/*` 32 | 33 | To generate Tibia-compatible `minimap/*` files based on the PNGs and JSON files in the `data` directory, run: 34 | 35 | ```sh 36 | tibia-maps --from-data=./data --output-dir=./minimap-new 37 | ``` 38 | 39 | The output is saved in the `minimap-new` directory. 40 | 41 | ## Author 42 | 43 | | [![twitter/mathias](https://gravatar.com/avatar/24e08a9ea84deb17ae121074d0f17125?s=70)](https://twitter.com/mathias "Follow @mathias on Twitter") | 44 | |---| 45 | | [Mathias Bynens](https://mathiasbynens.be/) | 46 | -------------------------------------------------------------------------------- /bin/cli.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { convertToMinimap } from '../src/to-minimap.mjs'; 4 | 5 | import { createRequire } from 'module'; 6 | const require = createRequire(import.meta.url); 7 | 8 | import path from 'node:path'; 9 | 10 | const argv = require('argh').argv; 11 | import fs from 'node:fs'; 12 | const fsp = fs.promises; 13 | const rimraf = require('rimraf'); 14 | 15 | import { convertFromMinimap } from '../src/from-minimap.mjs'; 16 | import { generateBoundsFromMinimap } from '../src/generate-bounds-from-minimap.mjs'; 17 | const info = require('../package.json'); 18 | 19 | const emptyDirectory = (path) => { 20 | return new Promise((resolve, reject) => { 21 | rimraf(`${path}/*`, () => { 22 | fsp.mkdir(path, { recursive: true }).then(() => { 23 | resolve(); 24 | }); 25 | }); 26 | }); 27 | }; 28 | 29 | const main = async () => { 30 | const excludeMarkers = argv['markers'] === false; 31 | const overlayGrid = argv['overlay-grid'] === true; 32 | 33 | if (process.argv.length == 2) { 34 | console.log(`${info.name} v${info.version} - ${info.homepage}`); 35 | console.log('\nUsage:\n'); 36 | console.log(`\t${info.name} --from-minimap=./minimap --output-dir=./data`); 37 | console.log(`\t${info.name} --from-minimap=./minimap --output-dir=./data --markers-only`); 38 | console.log(`\t${info.name} --from-minimap=./minimap --output-dir=./data --markers-only --union`); 39 | console.log(`\t${info.name} --from-data=./data --output-dir=./minimap --no-markers`); 40 | console.log(`\t${info.name} --from-data=./data --output-dir=./minimap-grid --overlay-grid`); 41 | console.log(`\t${info.name} --from-data=./data --extra=achievements,orcsoberfest --output-dir=./minimap`); 42 | process.exit(1); 43 | } 44 | 45 | if (argv['v'] || argv['version']) { 46 | console.log(`v${info.version}`); 47 | return process.exit(0); 48 | } 49 | 50 | if (!argv['from-minimap'] && !argv['from-data']) { 51 | console.log('Missing `--from-minimap` or `--from-data` flag.'); 52 | return process.exit(1); 53 | } 54 | 55 | if (argv['from-minimap'] && argv['from-data']) { 56 | console.log('Cannot combine `--from-minimap` with `--from-data`. Pick one.'); 57 | return process.exit(1); 58 | } 59 | 60 | if (argv['from-minimap']) { 61 | if (argv['from-minimap'] === true) { 62 | console.log('`--from-minimap` path not specified. Using the default, i.e. `minimap`.'); 63 | argv['from-minimap'] = 'minimap'; 64 | } 65 | const mapsDirectory = path.resolve(String(argv['from-minimap'])); 66 | if (!argv['output-dir'] || argv['output-dir'] === true) { 67 | console.log('`--output-dir` path not specified. Using the default, i.e. `data`.'); 68 | argv['output-dir'] = 'data'; 69 | } 70 | const dataDirectory = path.resolve(String(argv['output-dir'])); 71 | const markersOnly = argv['markers-only']; 72 | if (!markersOnly) { 73 | await emptyDirectory(dataDirectory); 74 | } 75 | const unionMode = argv['union']; 76 | const bounds = await generateBoundsFromMinimap(mapsDirectory, dataDirectory, !markersOnly); 77 | convertFromMinimap( 78 | bounds, mapsDirectory, dataDirectory, !excludeMarkers, markersOnly, unionMode 79 | ); 80 | return; 81 | } 82 | 83 | if (argv['from-data']) { 84 | if (argv['from-data'] === true) { 85 | console.log('`--from-data` path not specified. Using the default, i.e. `data`.'); 86 | argv['from-data'] = 'data'; 87 | } 88 | 89 | const dataDirectory = path.resolve(argv['from-data']); 90 | if (!argv['output-dir'] || argv['output-dir'] === true) { 91 | console.log('`--output-dir` path not specified. Using the default, i.e. `minimap-new`.'); 92 | argv['output-dir'] = 'minimap-new'; 93 | } 94 | 95 | const extra = (() => { 96 | if (!argv['extra'] || typeof argv['extra'] !== 'string') { 97 | return false; 98 | } 99 | const ids = argv['extra'].split(','); 100 | return ids.map(id => path.resolve(dataDirectory, '../extra/', id)); 101 | })(); 102 | 103 | const minimapDirectory = path.resolve(String(argv['output-dir'])); 104 | await emptyDirectory(minimapDirectory); 105 | await convertToMinimap(dataDirectory, minimapDirectory, extra, !excludeMarkers, overlayGrid); 106 | return; 107 | } 108 | }; 109 | 110 | main(); 111 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tibia-maps", 3 | "version": "5.3.0", 4 | "description": "A command-line utility to convert between binary Tibia maps and human-readable forms of the map data.", 5 | "homepage": "https://mths.be/tibiamaps", 6 | "main": "bin/cli.mjs", 7 | "bin": "bin/cli.mjs", 8 | "keywords": [ 9 | "data", 10 | "mmorpg", 11 | "tibia", 12 | "tibia-maps" 13 | ], 14 | "license": "MIT", 15 | "author": { 16 | "name": "Mathias Bynens", 17 | "url": "https://mathiasbynens.be/" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/tibiamaps/tibia-maps-script.git" 22 | }, 23 | "bugs": "https://github.com/tibiamaps/tibia-maps-script/issues", 24 | "files": [ 25 | "LICENSE-MIT.txt", 26 | "bin/", 27 | "src/" 28 | ], 29 | "directories": { 30 | "bin": "bin", 31 | "test": "test" 32 | }, 33 | "scripts": { 34 | "test": "node test/test.mjs" 35 | }, 36 | "dependencies": { 37 | "argh": "^1.0.0", 38 | "canvas": "^2.6.1", 39 | "glob": "^7.1.6", 40 | "mkdirp": "^1.0.4", 41 | "rimraf": "^3.0.2", 42 | "tibia-count-walkable-tiles": "^2.0.0", 43 | "tibia-minimap-png": "^2.0.0", 44 | "utf8": "^3.0.0", 45 | "windows-1252": "^1.0.0" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/array-to-minimap-marker.mjs: -------------------------------------------------------------------------------- 1 | import { createRequire } from 'module'; 2 | const require = createRequire(import.meta.url); 3 | 4 | const utf8 = require('utf8'); 5 | 6 | import { iconsByName } from './icons.mjs'; 7 | import { sortMarkers } from './sort-markers.mjs'; 8 | 9 | const coordinateToMinimapBytes = (x) => { 10 | // https://tibiamaps.io/guides/minimap-file-format#coordinates 11 | const x3 = x >> 14; 12 | const x1 = 0x80 + x % 0x80; 13 | const x2 = (x - 0x4000 * x3 - x1 + 0x4080) >> 7; 14 | return [ x1, x2, x3 ]; 15 | }; 16 | 17 | export const arrayToMinimapMarkerBuffer = (array) => { 18 | if (!array.sort) { array = []; } 19 | sortMarkers(array); 20 | // https://tibiamaps.io/guides/minimap-file-format#map-marker-data 21 | let result = Buffer.alloc(0); 22 | for (const marker of array) { 23 | const encodedDescription = utf8.encode(marker.description); 24 | const encodedDescriptionLength = encodedDescription.length; 25 | const markerSize = 20 + encodedDescriptionLength; 26 | // Assume x1, x2, x3 and y1, y2, y3 are all needed. 27 | const coordinateSize = 10; 28 | const markerBuffer = Buffer.alloc(markerSize); 29 | markerBuffer.writeUInt8(0x0A, 0); 30 | markerBuffer.writeUInt8(markerSize - 2, 1); 31 | markerBuffer.writeUInt8(0x0A, 2); 32 | markerBuffer.writeUInt8(coordinateSize, 3); 33 | markerBuffer.writeUInt8(0x08, 4); 34 | const [ x1, x2, x3 ] = coordinateToMinimapBytes(marker.x); 35 | markerBuffer.writeUInt8(x1, 5); 36 | markerBuffer.writeUInt8(x2, 6); 37 | markerBuffer.writeUInt8(x3, 7); 38 | markerBuffer.writeUInt8(0x10, 8); 39 | const [ y1, y2, y3 ] = coordinateToMinimapBytes(marker.y); 40 | markerBuffer.writeUInt8(y1, 9); 41 | markerBuffer.writeUInt8(y2, 10); 42 | markerBuffer.writeUInt8(y3, 11); 43 | markerBuffer.writeUInt8(0x18, 12); 44 | markerBuffer.writeUInt8(marker.z, 13); 45 | markerBuffer.writeUInt8(0x10, 14); 46 | const iconByte = iconsByName.get(marker.icon); 47 | console.assert(iconByte != null); 48 | markerBuffer.writeUInt8(iconByte, 15); 49 | markerBuffer.writeUInt8(0x1A, 16); 50 | markerBuffer.writeUInt8(encodedDescriptionLength, 17); 51 | console.assert( 52 | marker.description.length <= 100, 53 | 'Marker description should be 100 symbols or fewer for the minimap format' 54 | ); 55 | markerBuffer.write( 56 | encodedDescription, 57 | 18, 58 | encodedDescriptionLength, 59 | 'binary' 60 | ); 61 | markerBuffer.writeUInt8(0x20, 18 + encodedDescriptionLength); 62 | markerBuffer.writeUInt8(0x00, 19 + encodedDescriptionLength); 63 | result = Buffer.concat([result, markerBuffer]); 64 | } 65 | return result; 66 | }; 67 | -------------------------------------------------------------------------------- /src/colors.mjs: -------------------------------------------------------------------------------- 1 | const byByte = new Map([ 2 | [0x00, { r: 0, g: 0, b: 0 }], // black (empty) 3 | [0x0C, { r: 0, g: 102, b: 0 }], // dark green (tree) 4 | [0x18, { r: 0, g: 204, b: 0 }], // green (grass) 5 | [0x33, { r: 51, g: 102, b: 153 }], // light blue (water) 6 | [0x56, { r: 102, g: 102, b: 102 }], // dark gray (rock/mountain) 7 | [0x72, { r: 153, g: 51, b: 0 }], // dark brown (earth/stalagmite) 8 | [0x79, { r: 153, g: 102, b: 51 }], // brown (earth) 9 | [0x81, { r: 153, g: 153, b: 153 }], // gray (stone tile/cobbled pavement) 10 | [0x8C, { r: 153, g: 255, b: 102 }], // light green (light spot in grassy area) 11 | [0xB3, { r: 204, g: 255, b: 255 }], // light blue (ice) 12 | [0xBA, { r: 255, g: 51, b: 0 }], // red (wall) 13 | [0xC0, { r: 255, g: 102, b: 0 }], // orange (lava) 14 | [0xCF, { r: 255, g: 204, b: 153 }], // beige (sand) 15 | [0xD2, { r: 255, g: 255, b: 0 }], // yellow (ladder/stairs/hole/…) 16 | [0xD7, { r: 255, g: 255, b: 255 }], // white (snow) 17 | ]); 18 | 19 | export const byColor = new Map(); 20 | for (const [byteValue, color] of byByte) { 21 | const colorId = `${color.r},${color.g},${color.b}`; 22 | byColor.set(colorId, byteValue); 23 | } 24 | 25 | export const unexploredMapByte = 0x00; 26 | export const unexploredMap = byByte.get(unexploredMapByte); 27 | // The Tibia 11 client marks unwalkable paths as yellow. 28 | export const nonWalkablePath = byByte.get(0xD2); 29 | // Pink denotes “unexplored”. 30 | export const unexploredPath = { r: 0xFF, g: 0x00, b: 0xFF }; 31 | // https://github.com/tibiamaps/tibia-map-data/issues/158#issuecomment-858848120 32 | export const unexploredPathByte = 0xFE; 33 | -------------------------------------------------------------------------------- /src/from-minimap.mjs: -------------------------------------------------------------------------------- 1 | import { createRequire } from 'module'; 2 | const require = createRequire(import.meta.url); 3 | 4 | import fs from 'node:fs'; 5 | const fsp = fs.promises; 6 | import path from 'node:path'; 7 | 8 | const Canvas = require('canvas'); 9 | const Image = Canvas.Image; 10 | const utf8 = require('utf8'); 11 | 12 | const GLOBALS = {}; 13 | const resetContext = (context, fillStyle) => { 14 | context.fillStyle = fillStyle; 15 | context.fillRect(0, 0, GLOBALS.bounds.width, GLOBALS.bounds.height); 16 | }; 17 | 18 | import { unexploredMap, unexploredPath } from './colors.mjs'; 19 | import { glob } from './glob-promise.mjs'; 20 | import { handleParallel } from './handle-parallel.mjs'; 21 | import { iconsById } from './icons.mjs'; 22 | import { minimapIdToAbsoluteXyz } from './minimap-id-to-absolute-xyz.mjs'; 23 | import { saveCanvasToPng } from './save-canvas-to-png.mjs'; 24 | import { sortMarkers } from './sort-markers.mjs'; 25 | import { writeJson } from './write-json.mjs'; 26 | 27 | const minimapBytesToCoordinate = (x1, x2, x3) => { 28 | // https://tibiamaps.io/guides/minimap-file-format#coordinates 29 | return x1 + 0x80 * x2 + 0x4000 * x3 - 0x4080; 30 | }; 31 | 32 | const parseMarkerData = (buffer) => { 33 | // https://tibiamaps.io/guides/minimap-file-format#map-marker-data 34 | const markers = []; 35 | let index = 0; 36 | const length = buffer.length; 37 | 38 | // If there are no markers, our work is done here. 39 | if (length == 0) { 40 | return markers; 41 | } 42 | 43 | // For each marker… 44 | while (index < length) { 45 | const marker = {}; 46 | 47 | // The first byte is 0x0A. 48 | console.assert(buffer[index++] === 0x0A); 49 | // The second byte indicates the size of this marker’s data block (i.e. all 50 | // the following bytes). 51 | const markerSize = buffer.readUInt8(index++, 1); 52 | // The following byte is another 0x0A separator, indicating the start of the 53 | // coordinate data block. 54 | console.assert(buffer[index++] === 0x0A); 55 | // The next byte indicates the size of this marker’s coordinate data block. 56 | const coordinateSize = buffer.readUInt8(index++, 1); 57 | // For simplicity, we only support the coordinate sizes used on the official 58 | // servers. For those, `coordinateSize` is always 0x0A. 59 | console.assert(coordinateSize === 0x0A); 60 | // The 0x08 byte marks the start of the `x` coordinate data. 61 | console.assert(buffer[index++] === 0x08); 62 | // The next 1, 2, or 3 bytes represent the `x` coordinate. 63 | const x1 = buffer.readUInt8(index++, 1); 64 | const x2 = buffer.readUInt8(index++, 1); 65 | const x3 = buffer.readUInt8(index++, 1); 66 | marker.x = minimapBytesToCoordinate(x1, x2, x3); 67 | // The 0x10 byte marks the end of the `x` coordinate data. 68 | console.assert(buffer[index++] === 0x10); 69 | // The next 1, 2, or 3 bytes represent the `y` coordinate. 70 | const y1 = buffer.readUInt8(index++, 1); 71 | const y2 = buffer.readUInt8(index++, 1); 72 | const y3 = buffer.readUInt8(index++, 1); 73 | marker.y = minimapBytesToCoordinate(y1, y2, y3); 74 | // The 0x18 byte marks the end of the `x` coordinate data. 75 | console.assert(buffer[index++] === 0x18); 76 | // The next byte is the floor ID. 77 | marker.z = buffer.readUInt8(index++, 1); 78 | // The following byte is 0x10. 79 | console.assert(buffer[index++] === 0x10); 80 | // The next byte represents the image ID of the marker icon. 81 | const imageID = buffer.readUInt8(index++, 1); 82 | marker.icon = iconsById.get(imageID); 83 | // The next byte is 0x1A. 84 | console.assert(buffer[index++] === 0x1A); 85 | // The next byte indicates the size of the string that follows. 86 | const descriptionLength = buffer.readUInt8(index++, 1); 87 | // The following bytes represent the marker description as a UTF-8–encoded 88 | // string. 89 | const descriptionBuffer = buffer.slice(index, index + descriptionLength); 90 | index += descriptionLength; 91 | marker.description = utf8.decode( 92 | descriptionBuffer.toString('binary') 93 | ); 94 | // The next few bytes are usually 0x20 0x00, marking the end of the marker. 95 | // However, there are cases where the client produces a different format 96 | // for reasons unknown. 97 | // https://github.com/tibiamaps/tibia-maps-script/issues/21 98 | while (buffer[index] !== undefined && buffer[index] !== 0x0A) index++; 99 | 100 | // Create a sorted-by-key version of the marker object. 101 | const sorted = { 102 | description: marker.description, 103 | icon: marker.icon, 104 | x: marker.x, 105 | y: marker.y, 106 | z: marker.z, 107 | }; 108 | markers.push(sorted); 109 | } 110 | 111 | sortMarkers(markers); 112 | 113 | // Remove duplicate markers. 114 | const set = new Set(); 115 | const uniqueMarkers = markers.filter((marker) => { 116 | const serialized = JSON.stringify(marker).toLowerCase(); 117 | const isDuplicate = set.has(serialized); 118 | set.add(serialized); 119 | return !isDuplicate; 120 | }); 121 | return uniqueMarkers; 122 | }; 123 | 124 | const drawMapSection = async (mapContext, fileName) => { 125 | const id = path.basename(fileName, '.png').replace(/^Minimap_Color_/, ''); 126 | const coordinates = minimapIdToAbsoluteXyz(id); 127 | const xOffset = coordinates.x - GLOBALS.bounds.xMin; 128 | const yOffset = coordinates.y - GLOBALS.bounds.yMin; 129 | const buffer = await fsp.readFile(fileName); 130 | const image = new Image(); 131 | image.src = buffer; 132 | mapContext.drawImage(image, xOffset, yOffset, 256, 256); 133 | }; 134 | 135 | const drawPathSection = async (pathContext, fileName) => { 136 | const id = path.basename(fileName, '.png').replace(/^Minimap_WaypointCost_/, ''); 137 | const coordinates = minimapIdToAbsoluteXyz(id); 138 | const xOffset = coordinates.x - GLOBALS.bounds.xMin; 139 | const yOffset = coordinates.y - GLOBALS.bounds.yMin; 140 | const buffer = await fsp.readFile(fileName); 141 | const image = new Image(); 142 | image.src = buffer; 143 | pathContext.drawImage(image, xOffset, yOffset, 256, 256); 144 | }; 145 | 146 | const renderFloorMap = async (floorID, floorNumber, mapDirectory, dataDirectory) => { 147 | const bounds = GLOBALS.bounds; 148 | const mapCanvas = Canvas.createCanvas(bounds.width, bounds.height); 149 | const mapContext = mapCanvas.getContext('2d'); 150 | resetContext( 151 | mapContext, 152 | `rgb(${unexploredMap.r}, ${unexploredMap.g}, ${unexploredMap.b}` 153 | ); 154 | // Handle all map files for this floor. 155 | const files = await glob(`${mapDirectory}/Minimap_Color_*_${floorNumber}.png`); 156 | await handleParallel(files, (fileName) => { 157 | return drawMapSection(mapContext, fileName); 158 | }); 159 | await saveCanvasToPng( 160 | `${dataDirectory}/floor-${floorID}-map.png`, 161 | mapCanvas 162 | ); 163 | }; 164 | 165 | const renderFloorPath = async (floorID, floorNumber, mapDirectory, dataDirectory) => { 166 | const bounds = GLOBALS.bounds; 167 | const pathCanvas = Canvas.createCanvas(bounds.width, bounds.height); 168 | const pathContext = pathCanvas.getContext('2d'); 169 | resetContext( 170 | pathContext, 171 | `rgb(${unexploredPath.r}, ${unexploredPath.g}, ${unexploredPath.b}` 172 | ); 173 | // Handle all path files for this floor. 174 | const files = await glob(`${mapDirectory}/Minimap_WaypointCost_*_${floorNumber}.png`); 175 | await handleParallel(files, (fileName) => { 176 | return drawPathSection(pathContext, fileName); 177 | }); 178 | await saveCanvasToPng( 179 | `${dataDirectory}/floor-${floorID}-path.png`, 180 | pathCanvas 181 | ); 182 | }; 183 | 184 | const renderFloor = (floorID, mapDirectory, dataDirectory) => { 185 | console.log(`Rendering floor ${floorID}…`); 186 | const floorNumber = Number(floorID); 187 | return Promise.all([ 188 | renderFloorMap(floorID, floorNumber, mapDirectory, dataDirectory), 189 | renderFloorPath(floorID, floorNumber, mapDirectory, dataDirectory), 190 | ]); 191 | }; 192 | 193 | const mergeMarkers = (...markerGroups) => { 194 | const markerMap = new Map(); 195 | 196 | for (const markerGroup of markerGroups) { 197 | for (const marker of markerGroup) { 198 | const key = `${marker.x}.${marker.y}.${marker.z}`; 199 | markerMap.set(key, marker); 200 | } 201 | } 202 | 203 | return sortMarkers([...markerMap.values()]); 204 | }; 205 | 206 | export const convertFromMinimap = async (bounds, mapDirectory, dataDirectory, includeMarkers, markersOnly, unionMode = false) => { 207 | GLOBALS.bounds = bounds; 208 | if (!mapDirectory) { 209 | mapDirectory = 'minimap'; 210 | } 211 | if (!dataDirectory) { 212 | dataDirectory = 'data'; 213 | } 214 | if (!markersOnly) { 215 | await handleParallel(bounds.floorIDs, (floorID) => { 216 | return renderFloor(floorID, mapDirectory, dataDirectory); 217 | }); 218 | } 219 | const fileName = `${mapDirectory}/minimapmarkers.bin`; 220 | if (!fs.existsSync(fileName)) { 221 | return; 222 | } 223 | const buffer = await fsp.readFile(fileName); 224 | let allMarkers = parseMarkerData(buffer); 225 | if (unionMode) { 226 | const baseFileName = `${dataDirectory}/markers.json`; 227 | if (fs.existsSync(baseFileName)) { 228 | const baseJson = await fsp.readFile(baseFileName, 'utf8'); 229 | const baseMarkers = JSON.parse(baseJson); 230 | 231 | allMarkers = mergeMarkers(baseMarkers, allMarkers); 232 | } 233 | } 234 | writeJson( 235 | `${dataDirectory}/markers.json`, 236 | includeMarkers && allMarkers ? allMarkers : [] 237 | ); 238 | }; 239 | -------------------------------------------------------------------------------- /src/generate-bounds-from-minimap.mjs: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | 3 | import { glob } from './glob-promise.mjs'; 4 | import { writeJson } from './write-json.mjs'; 5 | 6 | import { minimapIdToAbsoluteXyz } from './minimap-id-to-absolute-xyz.mjs'; 7 | 8 | import { createRequire } from 'module'; 9 | const require = createRequire(import.meta.url); 10 | 11 | export const generateBoundsFromMinimap = async (mapsDirectory, dataDirectory, writeToDisk) => { 12 | const files = await glob(`${mapsDirectory}/*.png`); 13 | const bounds = { 14 | xMin: +Infinity, 15 | xMax: -Infinity, 16 | yMin: +Infinity, 17 | yMax: -Infinity, 18 | zMin: +Infinity, 19 | zMax: -Infinity, 20 | }; 21 | const floorIDs = []; 22 | for (const file of files) { 23 | const id = path.basename(file, '.png').replace(/^Minimap_(?:Color|WaypointCost)_/, ''); 24 | const coordinates = minimapIdToAbsoluteXyz(id); 25 | const { x, y, z } = coordinates; 26 | if (bounds.xMin > x) { 27 | bounds.xMin = x; 28 | } 29 | if (bounds.xMax < x) { 30 | bounds.xMax = x; 31 | } 32 | if (bounds.yMin > y) { 33 | bounds.yMin = y; 34 | } 35 | if (bounds.yMax < y) { 36 | bounds.yMax = y; 37 | } 38 | if (bounds.zMin > z) { 39 | bounds.zMin = z; 40 | } 41 | if (bounds.zMax < z) { 42 | bounds.zMax = z; 43 | } 44 | const floorID = String(z).padStart(2, '0'); 45 | if (!floorIDs.includes(floorID)) { 46 | floorIDs.push(floorID); 47 | } 48 | } 49 | bounds.width = 256 + bounds.xMax - bounds.xMin; 50 | bounds.height = 256 + bounds.yMax - bounds.yMin; 51 | bounds.floorIDs = floorIDs.sort(); 52 | if (writeToDisk) { 53 | writeJson(`${dataDirectory}/bounds.json`, bounds); 54 | } 55 | return bounds; 56 | }; 57 | -------------------------------------------------------------------------------- /src/glob-promise.mjs: -------------------------------------------------------------------------------- 1 | import { createRequire } from 'module'; 2 | const require = createRequire(import.meta.url); 3 | 4 | const _glob = require('glob'); 5 | 6 | export const glob = (pattern) => { 7 | return new Promise((resolve, reject) => { 8 | _glob(pattern, (error, files) => { 9 | if (error) { 10 | console.log(error); 11 | reject(error); 12 | } 13 | resolve(files); 14 | }); 15 | }); 16 | }; 17 | -------------------------------------------------------------------------------- /src/handle-parallel.mjs: -------------------------------------------------------------------------------- 1 | export const handleParallel = (array, callback) => { 2 | const promises = array.map(element => callback(element)); 3 | return Promise.all(promises); 4 | }; 5 | -------------------------------------------------------------------------------- /src/icons.mjs: -------------------------------------------------------------------------------- 1 | export const iconsById = new Map([ 2 | [0x00, 'checkmark'], // green checkmark ✔ 3 | [0x01, '?'], // blue question mark ❓ 4 | [0x02, '!'], // red exclamation mark ❗ 5 | [0x03, 'star'], // orange star 🟊 6 | [0x04, 'crossmark'], // bright red crossmark ❌ 7 | [0x05, 'cross'], // dark red cross 🕇 8 | [0x06, 'mouth'], // mouth with red lips 👄 9 | [0x07, 'spear'], // spear 🏹 10 | [0x08, 'sword'], // sword ⚔ 11 | [0x09, 'flag'], // blue flag ⚑ 12 | [0x0A, 'lock'], // golden lock 🔒 13 | [0x0B, 'bag'], // brown bag 👛 14 | [0x0C, 'skull'], // skull 💀 15 | [0x0D, '$'], // green dollar sign 💰💲 16 | [0x0E, 'red up'], // red arrow up ⬆️🔺 17 | [0x0F, 'red down'], // red arrow down ⬇🔻 18 | [0x10, 'red right'], // red arrow right ➡️ 19 | [0x11, 'red left'], // red arrow left ⬅️ 20 | [0x12, 'up'], // green arrow up ⬆ 21 | [0x13, 'down'], // green arrow down ⬇ 22 | ]); 23 | 24 | export const iconsByName = new Map(); 25 | for (const [id, name] of iconsById) { 26 | iconsByName.set(name, id); 27 | } 28 | -------------------------------------------------------------------------------- /src/minimap-id-to-absolute-xyz.mjs: -------------------------------------------------------------------------------- 1 | export const minimapIdToAbsoluteXyz = (id) => { 2 | const [x, y, z] = id.split('_').map((string) => Number(string)); 3 | return { x, y, z }; 4 | }; 5 | -------------------------------------------------------------------------------- /src/pixel-data-to-map.mjs: -------------------------------------------------------------------------------- 1 | import { byColor, unexploredMapByte } from './colors.mjs'; 2 | 3 | export const pixelDataToMapBuffer = (pixels) => { 4 | const data = pixels.data; 5 | // https://tibiamaps.io/guides/map-file-format#visual-map-data 6 | let hasData = false; 7 | const buffer = Buffer.alloc(0x10000); 8 | let bufferIndex = -1; 9 | let xIndex = -1; 10 | while (++xIndex < 256) { 11 | const xOffset = xIndex * 4; 12 | let yIndex = -1; 13 | while (++yIndex < 256) { 14 | const yOffset = yIndex * 256 * 4; 15 | const offset = yOffset + xOffset; 16 | const r = data[offset]; 17 | const g = data[offset + 1]; 18 | const b = data[offset + 2]; 19 | // Discard alpha channel data; it’s always 0xFF anyway. 20 | //const a = data[offset + 3]; 21 | // Get the byte value that corresponds to this color. 22 | const id = `${r},${g},${b}`; 23 | const byteValue = byColor.get(id); 24 | console.assert(byteValue != null, `Unknown color ID: ${id}`); 25 | buffer.writeUInt8(byteValue, ++bufferIndex); 26 | if (!hasData && byteValue !== unexploredMapByte) { 27 | hasData = true; 28 | } 29 | } 30 | } 31 | return hasData && buffer; 32 | }; 33 | -------------------------------------------------------------------------------- /src/pixel-data-to-path.mjs: -------------------------------------------------------------------------------- 1 | import { 2 | nonWalkablePath, 3 | unexploredPath, 4 | unexploredPathByte 5 | } from './colors.mjs'; 6 | 7 | export const pixelDataToPathBuffer = (pixels, isGroundFloor) => { 8 | // https://tibiamaps.io/guides/map-file-format#pathfinding-data 9 | const data = pixels.data; 10 | let hasData = isGroundFloor; 11 | const buffer = Buffer.alloc(0x10000); 12 | let bufferIndex = -1; 13 | let xIndex = -1; 14 | while (++xIndex < 256) { 15 | const xOffset = xIndex * 4; 16 | let yIndex = -1; 17 | while (++yIndex < 256) { 18 | const yOffset = yIndex * 256 * 4; 19 | const offset = yOffset + xOffset; 20 | const r = data[offset]; 21 | const g = data[offset + 1]; 22 | const b = data[offset + 2]; 23 | // Discard alpha channel data; it’s always 0xFF anyway. 24 | //const a = data[offset + 3]; 25 | let byteValue; 26 | if ( 27 | ( 28 | r === unexploredPath.r && 29 | b === unexploredPath.b && 30 | g === unexploredPath.g 31 | ) 32 | ) { 33 | byteValue = unexploredPathByte; 34 | } else { 35 | // Verify that `r, `g`, and `b` are either equal or the non-walkable 36 | // color. 37 | console.assert( 38 | (r === g && r === b) || 39 | ( 40 | r === nonWalkablePath.r && 41 | g === nonWalkablePath.g && 42 | b === nonWalkablePath.b 43 | ), 44 | `${r},${g},${b}` 45 | ); 46 | hasData = true; 47 | // Get the byte value that corresponds to this color. 48 | byteValue = r; 49 | } 50 | buffer.writeUInt8(byteValue, ++bufferIndex); 51 | } 52 | } 53 | return hasData && buffer; 54 | }; 55 | -------------------------------------------------------------------------------- /src/png-to-buffer.mjs: -------------------------------------------------------------------------------- 1 | import { pixelDataToMapBuffer } from './pixel-data-to-map.mjs'; 2 | import { pixelDataToPathBuffer } from './pixel-data-to-path.mjs'; 3 | 4 | import { createRequire } from 'module'; 5 | const require = createRequire(import.meta.url); 6 | 7 | const Canvas = require('canvas'); 8 | const Image = Canvas.Image; 9 | 10 | // Image width and height. 11 | const PIXELS = 256; 12 | 13 | const canvas = Canvas.createCanvas(PIXELS, PIXELS); 14 | const context = canvas.getContext('2d'); 15 | 16 | export const pngToBuffer = (filePath) => { 17 | const image = new Image(); 18 | image.src = filePath; 19 | context.drawImage(image, 0, 0, PIXELS, PIXELS); 20 | const pixels = context.getImageData(0, 0, PIXELS, PIXELS); 21 | const isColor = filePath.includes('_Color_'); 22 | if (isColor) { 23 | return pixelDataToMapBuffer(pixels); 24 | } 25 | const isGroundFloor = filePath.endsWith('_7.png'); 26 | return pixelDataToPathBuffer(pixels, isGroundFloor); 27 | }; 28 | -------------------------------------------------------------------------------- /src/save-canvas-to-png.mjs: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | 3 | export const saveCanvasToPng = (fileName, canvas) => { 4 | return new Promise((resolve, reject) => { 5 | const writeStream = fs.createWriteStream(fileName); 6 | const pngStream = canvas.pngStream(); 7 | pngStream.on('data', (chunk) => { 8 | writeStream.write(chunk) 9 | }); 10 | pngStream.on('end', () => { 11 | //console.log(`${fileName} created successfully.`); 12 | resolve(); 13 | }); 14 | }); 15 | }; 16 | -------------------------------------------------------------------------------- /src/sort-markers.mjs: -------------------------------------------------------------------------------- 1 | export const sortMarkers = (markers) => { 2 | // Sort markers so they start in the top left, then go from top to bottom. 3 | // Example: 4 | // · 2 · 4 · · · 5 | // 1 · 3 · · · 7 6 | // · · · 5 · 6 · 7 | markers.sort((a, b) => { 8 | // Represent each marker as a number of the form 9 | // zz_xxxxx_yyyyy 10 | // 01_00000_00000 11 | // and then just compare the numbers. 12 | return ( 13 | (a.z * 1_00000_00000 + a.x * 1_00000 + a.y) - 14 | (b.z * 1_00000_00000 + b.x * 1_00000 + b.y) 15 | ); 16 | }); 17 | return markers; 18 | }; 19 | -------------------------------------------------------------------------------- /src/to-minimap.mjs: -------------------------------------------------------------------------------- 1 | import { wrapColorData, wrapWaypointData } from 'tibia-minimap-png'; 2 | 3 | import { handleParallel } from './handle-parallel.mjs'; 4 | 5 | import { arrayToMinimapMarkerBuffer } from './array-to-minimap-marker.mjs'; 6 | import { unexploredMapByte, unexploredPathByte } from './colors.mjs'; 7 | import { pixelDataToMapBuffer } from './pixel-data-to-map.mjs'; 8 | import { pixelDataToPathBuffer } from './pixel-data-to-path.mjs'; 9 | import { pngToBuffer } from './png-to-buffer.mjs'; 10 | import { sortMarkers } from './sort-markers.mjs'; 11 | 12 | import { createRequire } from 'module'; 13 | const require = createRequire(import.meta.url); 14 | 15 | import fs from 'node:fs'; 16 | const fsp = fs.promises; 17 | 18 | import path from 'node:path'; 19 | 20 | const Canvas = require('canvas'); 21 | const Image = Canvas.Image; 22 | 23 | const EMPTY_MAP_BUFFER = Buffer.alloc(0x10000, unexploredMapByte); 24 | const EMPTY_PATH_BUFFER = Buffer.alloc(0x10000, unexploredPathByte); 25 | 26 | const GLOBALS = {}; 27 | 28 | const writeBuffer = (fileName, buffer) => { 29 | if (buffer == null) { 30 | console.log('Undefined buffer; skipping creating `' + fileName + '`'); 31 | return; 32 | } 33 | return fsp.writeFile(fileName, buffer); 34 | }; 35 | 36 | const forEachTile = (context, map, createBufferCallback, writeBufferCallback, floorID) => { 37 | const isGroundFloor = floorID == '07'; 38 | const z = Number(floorID); 39 | const bounds = GLOBALS.bounds; 40 | const image = new Image(); 41 | image.src = map; 42 | context.drawImage(image, 0, 0, bounds.width, bounds.height); 43 | // Extract each 256×256px tile. 44 | let yOffset = 0; 45 | while (yOffset < bounds.height) { 46 | const y = bounds.yMin + yOffset; 47 | let xOffset = 0; 48 | while (xOffset < bounds.width) { 49 | const x = bounds.xMin + xOffset; 50 | const pixels = context.getImageData(xOffset, yOffset, 256, 256); 51 | const buffer = createBufferCallback(pixels, isGroundFloor); 52 | const id = `${x}_${y}_${z}`; 53 | if (buffer) { 54 | writeBufferCallback(buffer, id); 55 | } 56 | xOffset += 256; 57 | } 58 | yOffset += 256; 59 | } 60 | }; 61 | 62 | const createBinaryMap = async (floorID) => { 63 | const bounds = GLOBALS.bounds; 64 | const canvas = Canvas.createCanvas(bounds.width, bounds.height); 65 | const context = canvas.getContext('2d'); 66 | const map = await fsp.readFile(`${GLOBALS.dataDirectory}/floor-${floorID}-map.png`); 67 | forEachTile(context, map, pixelDataToMapBuffer, writeBinaryMapBuffer, floorID); 68 | }; 69 | 70 | GLOBALS.mapIds = new Set(); 71 | const writeBinaryMapBuffer = (buffer, id) => { 72 | GLOBALS.mapIds.add(id); 73 | const fileName = `Minimap_Color_${id}.png`; 74 | const dest = `${GLOBALS.outputPath}/${fileName}`; 75 | if (GLOBALS.extraMap.has(fileName)) { 76 | const source = GLOBALS.extraMap.get(fileName); 77 | buffer = pngToBuffer(source); 78 | } 79 | GLOBALS.ioPromises.push(writeBuffer( 80 | dest, 81 | wrapColorData(buffer, { overlayGrid: GLOBALS.overlayGrid }) 82 | )); 83 | }; 84 | 85 | const createBinaryPath = async (floorID) => { 86 | const bounds = GLOBALS.bounds; 87 | const canvas = Canvas.createCanvas(bounds.width, bounds.height); 88 | const context = canvas.getContext('2d'); 89 | const map = await fsp.readFile(`${GLOBALS.dataDirectory}/floor-${floorID}-path.png`); 90 | forEachTile(context, map, pixelDataToPathBuffer, writeBinaryPathBuffer, floorID); 91 | }; 92 | 93 | GLOBALS.pathIds = new Set(); 94 | const writeBinaryPathBuffer = (buffer, id) => { 95 | GLOBALS.pathIds.add(id); 96 | const fileName = `Minimap_WaypointCost_${id}.png`; 97 | const dest = `${GLOBALS.outputPath}/${fileName}`; 98 | if (GLOBALS.extraMap.has(fileName)) { 99 | const source = GLOBALS.extraMap.get(fileName); 100 | buffer = pngToBuffer(source); 101 | } 102 | GLOBALS.ioPromises.push(writeBuffer( 103 | dest, 104 | wrapWaypointData(buffer) 105 | )); 106 | }; 107 | 108 | let MINIMAP_MARKERS = Buffer.alloc(0); 109 | const createBinaryMarkers = async (extra) => { 110 | const getMarkers = async (dir) => { 111 | const json = await fsp.readFile(`${dir}/markers.json`, 'utf8'); 112 | const markers = JSON.parse(json); 113 | return markers; 114 | }; 115 | const dirs = [ 116 | GLOBALS.dataDirectory, 117 | ]; 118 | if (extra) { 119 | dirs.push(...extra); 120 | } 121 | const parts = await Promise.all(dirs.map(getMarkers)); 122 | const markers = sortMarkers(parts.flat()); 123 | const minimapMarkers = arrayToMinimapMarkerBuffer(markers); 124 | // TODO: To match the Tibia installer’s import functionality, the markers 125 | // are supposed to be ordered by their `x` coordinate value, then by 126 | // their `y` coordinate value, in ascending order. 127 | MINIMAP_MARKERS = minimapMarkers; 128 | return minimapMarkers; 129 | }; 130 | 131 | export const convertToMinimap = async (dataDirectory, outputPath, extra, includeMarkers, overlayGrid) => { 132 | if (!dataDirectory) { 133 | dataDirectory = 'data'; 134 | } 135 | if (!outputPath) { 136 | outputPath = 'minimap-new'; 137 | } 138 | GLOBALS.dataDirectory = dataDirectory; 139 | GLOBALS.extra = extra; 140 | GLOBALS.extraMap = (() => { 141 | const map = new Map(); 142 | if (!extra) return map; 143 | for (const dir of extra) { 144 | const images = fs.readdirSync(dir).filter(file => file.endsWith('.png')); 145 | for (const image of images) { 146 | map.set(image, path.resolve(dir, image)); 147 | } 148 | } 149 | return map; 150 | })(); 151 | GLOBALS.outputPath = outputPath; 152 | GLOBALS.overlayGrid = overlayGrid; 153 | GLOBALS.ioPromises = []; 154 | const bounds = JSON.parse(fs.readFileSync(`${dataDirectory}/bounds.json`)); 155 | GLOBALS.bounds = bounds; 156 | GLOBALS.canvas = Canvas.createCanvas(bounds.width, bounds.height); 157 | GLOBALS.context = GLOBALS.canvas.getContext('2d'); 158 | const floorIDs = bounds.floorIDs; 159 | try { 160 | const bufferPromises = [ 161 | handleParallel(floorIDs, createBinaryMap), 162 | handleParallel(floorIDs, createBinaryPath), 163 | ]; 164 | if (includeMarkers) { 165 | bufferPromises.push(createBinaryMarkers(extra)); 166 | } 167 | await Promise.all(bufferPromises); 168 | // Check for `Color` files lacking a corresponding `WaypointCost` 169 | // file, and force their creation. 170 | // https://github.com/tibiamaps/tibia-map-data/issues/105#issuecomment-714613895 171 | const missingWaypointIds = [...GLOBALS.mapIds] 172 | .filter(fileName => !GLOBALS.pathIds.has(fileName)); 173 | for (const id of missingWaypointIds) { 174 | console.log('Creating missing `WaypointCost` file:', id); 175 | writeBinaryPathBuffer(EMPTY_PATH_BUFFER, id); 176 | } 177 | if (includeMarkers && MINIMAP_MARKERS.length) { 178 | // The Tibia 11 installer doesn’t create the file if no markers are set. 179 | GLOBALS.ioPromises.push(writeBuffer(`${outputPath}/minimapmarkers.bin`, MINIMAP_MARKERS)); 180 | } 181 | // Wait for all file operations to complete. 182 | await Promise.all(GLOBALS.ioPromises); 183 | } catch (exception) { 184 | console.error(exception.stack); 185 | throw exception; 186 | } 187 | }; 188 | -------------------------------------------------------------------------------- /src/write-json.mjs: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | 3 | export const writeJson = (fileName, data) => { 4 | const writeStream = fs.createWriteStream(fileName); 5 | const json = JSON.stringify(data, null, '\t'); 6 | writeStream.write(`${json}\n`); 7 | writeStream.end(); 8 | //console.log(`${fileName} created successfully.`); 9 | }; 10 | -------------------------------------------------------------------------------- /test/data-union-base/markers.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "description": "Stoner", 4 | "icon": "red down", 5 | "x": 32098, 6 | "y": 31371, 7 | "z": 8 8 | }, 9 | { 10 | "description": "Start", 11 | "icon": "star", 12 | "x": 33051, 13 | "y": 33694, 14 | "z": 8 15 | } 16 | ] 17 | -------------------------------------------------------------------------------- /test/data-union-new/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tibiamaps/tibia-maps-script/3d44af50fd228fb22a08c9d5286100f427179ce0/test/data-union-new/.gitkeep -------------------------------------------------------------------------------- /test/data-union/markers.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "description": "Foo’s ïñtërnâtiônàlizætiøn workshop", 4 | "icon": "star", 5 | "x": 32064, 6 | "y": 31883, 7 | "z": 5 8 | }, 9 | { 10 | "description": "Buddel", 11 | "icon": "flag", 12 | "x": 32021, 13 | "y": 31294, 14 | "z": 8 15 | }, 16 | { 17 | "description": "Stone Golems", 18 | "icon": "red down", 19 | "x": 32098, 20 | "y": 31371, 21 | "z": 8 22 | }, 23 | { 24 | "description": "Start", 25 | "icon": "star", 26 | "x": 33051, 27 | "y": 33694, 28 | "z": 8 29 | } 30 | ] 31 | -------------------------------------------------------------------------------- /test/extra/achievements/Minimap_Color_32000_30976_5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tibiamaps/tibia-maps-script/3d44af50fd228fb22a08c9d5286100f427179ce0/test/extra/achievements/Minimap_Color_32000_30976_5.png -------------------------------------------------------------------------------- /test/extra/achievements/Minimap_Color_32256_32256_7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tibiamaps/tibia-maps-script/3d44af50fd228fb22a08c9d5286100f427179ce0/test/extra/achievements/Minimap_Color_32256_32256_7.png -------------------------------------------------------------------------------- /test/extra/achievements/Minimap_WaypointCost_32000_31744_5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tibiamaps/tibia-maps-script/3d44af50fd228fb22a08c9d5286100f427179ce0/test/extra/achievements/Minimap_WaypointCost_32000_31744_5.png -------------------------------------------------------------------------------- /test/extra/achievements/markers.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "description": "Digging spot (Gold Digger achievement)", 4 | "icon": "star", 5 | "x": 33126, 6 | "y": 32644, 7 | "z": 7 8 | }, 9 | { 10 | "description": "Digging spot (Gold Digger achievement)", 11 | "icon": "star", 12 | "x": 33127, 13 | "y": 32570, 14 | "z": 7 15 | } 16 | ] 17 | -------------------------------------------------------------------------------- /test/minimap-extra-new/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tibiamaps/tibia-maps-script/3d44af50fd228fb22a08c9d5286100f427179ce0/test/minimap-extra-new/.gitkeep -------------------------------------------------------------------------------- /test/minimap-extra/Minimap_Color_32000_30976_15.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tibiamaps/tibia-maps-script/3d44af50fd228fb22a08c9d5286100f427179ce0/test/minimap-extra/Minimap_Color_32000_30976_15.png -------------------------------------------------------------------------------- /test/minimap-extra/Minimap_Color_32000_30976_5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tibiamaps/tibia-maps-script/3d44af50fd228fb22a08c9d5286100f427179ce0/test/minimap-extra/Minimap_Color_32000_30976_5.png -------------------------------------------------------------------------------- /test/minimap-extra/Minimap_Color_32000_31232_8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tibiamaps/tibia-maps-script/3d44af50fd228fb22a08c9d5286100f427179ce0/test/minimap-extra/Minimap_Color_32000_31232_8.png -------------------------------------------------------------------------------- /test/minimap-extra/Minimap_Color_32000_31744_5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tibiamaps/tibia-maps-script/3d44af50fd228fb22a08c9d5286100f427179ce0/test/minimap-extra/Minimap_Color_32000_31744_5.png -------------------------------------------------------------------------------- /test/minimap-extra/Minimap_WaypointCost_32000_30976_15.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tibiamaps/tibia-maps-script/3d44af50fd228fb22a08c9d5286100f427179ce0/test/minimap-extra/Minimap_WaypointCost_32000_30976_15.png -------------------------------------------------------------------------------- /test/minimap-extra/Minimap_WaypointCost_32000_31232_8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tibiamaps/tibia-maps-script/3d44af50fd228fb22a08c9d5286100f427179ce0/test/minimap-extra/Minimap_WaypointCost_32000_31232_8.png -------------------------------------------------------------------------------- /test/minimap-extra/Minimap_WaypointCost_32000_31744_5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tibiamaps/tibia-maps-script/3d44af50fd228fb22a08c9d5286100f427179ce0/test/minimap-extra/Minimap_WaypointCost_32000_31744_5.png -------------------------------------------------------------------------------- /test/minimap-extra/minimapmarkers.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tibiamaps/tibia-maps-script/3d44af50fd228fb22a08c9d5286100f427179ce0/test/minimap-extra/minimapmarkers.bin -------------------------------------------------------------------------------- /test/minimap-grid-new/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tibiamaps/tibia-maps-script/3d44af50fd228fb22a08c9d5286100f427179ce0/test/minimap-grid-new/.gitkeep -------------------------------------------------------------------------------- /test/minimap-grid/Minimap_Color_32000_30976_15.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tibiamaps/tibia-maps-script/3d44af50fd228fb22a08c9d5286100f427179ce0/test/minimap-grid/Minimap_Color_32000_30976_15.png -------------------------------------------------------------------------------- /test/minimap-grid/Minimap_Color_32000_30976_5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tibiamaps/tibia-maps-script/3d44af50fd228fb22a08c9d5286100f427179ce0/test/minimap-grid/Minimap_Color_32000_30976_5.png -------------------------------------------------------------------------------- /test/minimap-grid/Minimap_Color_32000_31232_8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tibiamaps/tibia-maps-script/3d44af50fd228fb22a08c9d5286100f427179ce0/test/minimap-grid/Minimap_Color_32000_31232_8.png -------------------------------------------------------------------------------- /test/minimap-grid/Minimap_Color_32000_31744_5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tibiamaps/tibia-maps-script/3d44af50fd228fb22a08c9d5286100f427179ce0/test/minimap-grid/Minimap_Color_32000_31744_5.png -------------------------------------------------------------------------------- /test/minimap-grid/Minimap_WaypointCost_32000_30976_15.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tibiamaps/tibia-maps-script/3d44af50fd228fb22a08c9d5286100f427179ce0/test/minimap-grid/Minimap_WaypointCost_32000_30976_15.png -------------------------------------------------------------------------------- /test/minimap-grid/Minimap_WaypointCost_32000_31232_8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tibiamaps/tibia-maps-script/3d44af50fd228fb22a08c9d5286100f427179ce0/test/minimap-grid/Minimap_WaypointCost_32000_31232_8.png -------------------------------------------------------------------------------- /test/minimap-grid/Minimap_WaypointCost_32000_31744_5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tibiamaps/tibia-maps-script/3d44af50fd228fb22a08c9d5286100f427179ce0/test/minimap-grid/Minimap_WaypointCost_32000_31744_5.png -------------------------------------------------------------------------------- /test/minimap-grid/minimapmarkers.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tibiamaps/tibia-maps-script/3d44af50fd228fb22a08c9d5286100f427179ce0/test/minimap-grid/minimapmarkers.bin -------------------------------------------------------------------------------- /test/minimap-new/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tibiamaps/tibia-maps-script/3d44af50fd228fb22a08c9d5286100f427179ce0/test/minimap-new/.gitkeep -------------------------------------------------------------------------------- /test/minimap/Minimap_Color_32000_30976_15.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tibiamaps/tibia-maps-script/3d44af50fd228fb22a08c9d5286100f427179ce0/test/minimap/Minimap_Color_32000_30976_15.png -------------------------------------------------------------------------------- /test/minimap/Minimap_Color_32000_30976_5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tibiamaps/tibia-maps-script/3d44af50fd228fb22a08c9d5286100f427179ce0/test/minimap/Minimap_Color_32000_30976_5.png -------------------------------------------------------------------------------- /test/minimap/Minimap_Color_32000_31232_8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tibiamaps/tibia-maps-script/3d44af50fd228fb22a08c9d5286100f427179ce0/test/minimap/Minimap_Color_32000_31232_8.png -------------------------------------------------------------------------------- /test/minimap/Minimap_Color_32000_31744_5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tibiamaps/tibia-maps-script/3d44af50fd228fb22a08c9d5286100f427179ce0/test/minimap/Minimap_Color_32000_31744_5.png -------------------------------------------------------------------------------- /test/minimap/Minimap_WaypointCost_32000_30976_15.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tibiamaps/tibia-maps-script/3d44af50fd228fb22a08c9d5286100f427179ce0/test/minimap/Minimap_WaypointCost_32000_30976_15.png -------------------------------------------------------------------------------- /test/minimap/Minimap_WaypointCost_32000_31232_8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tibiamaps/tibia-maps-script/3d44af50fd228fb22a08c9d5286100f427179ce0/test/minimap/Minimap_WaypointCost_32000_31232_8.png -------------------------------------------------------------------------------- /test/minimap/Minimap_WaypointCost_32000_31744_5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tibiamaps/tibia-maps-script/3d44af50fd228fb22a08c9d5286100f427179ce0/test/minimap/Minimap_WaypointCost_32000_31744_5.png -------------------------------------------------------------------------------- /test/minimap/minimapmarkers.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tibiamaps/tibia-maps-script/3d44af50fd228fb22a08c9d5286100f427179ce0/test/minimap/minimapmarkers.bin -------------------------------------------------------------------------------- /test/test.mjs: -------------------------------------------------------------------------------- 1 | import { copyFileSync, fstat } from 'node:fs'; 2 | import { execSync } from 'node:child_process'; 3 | import { dirname } from 'node:path'; 4 | import { chdir } from 'node:process'; 5 | import { fileURLToPath } from 'node:url'; 6 | import { compareDir, compareMarkerFiles, readFile } from './util.mjs'; 7 | 8 | chdir(dirname(fileURLToPath(import.meta.url))); 9 | 10 | execSync('npm link'); 11 | execSync('tibia-maps --from-minimap=minimap --output-dir=data'); 12 | 13 | 14 | // Check if the generated map files based on the generated PNG and JSON data 15 | // match the original map files, and call out any differences. 16 | execSync('tibia-maps --from-data=data --output-dir=minimap-new'); 17 | compareDir('minimap'); 18 | 19 | 20 | // Check if `--overlay-grid` works correctly. 21 | execSync('tibia-maps --from-data=data --output-dir=minimap-grid-new --overlay-grid'); 22 | compareDir('minimap-grid'); 23 | 24 | 25 | // Check if `--extra` works correctly. 26 | execSync('tibia-maps --from-data=data --output-dir=minimap-extra-new --extra=achievements'); 27 | compareDir('minimap-extra'); 28 | 29 | 30 | // Check if `--no-markers` skips importing the marker data. 31 | execSync('tibia-maps --from-minimap=minimap --output-dir=data-without-markers --no-markers'); 32 | const markers = JSON.parse(readFile('data-without-markers/markers.json')); 33 | if (markers.length > 0) { 34 | console.error('Error: `--no-markers` extracted marker data anyway! (data-without-markers/markers.json)'); 35 | } 36 | 37 | 38 | // Check if `--union` works correctly. 39 | copyFileSync('data-union-base/markers.json', 'data-union-new/markers.json'); 40 | execSync('tibia-maps --union --markers-only --from-minimap=minimap --output-dir=data-union-new'); 41 | compareMarkerFiles('data-union'); 42 | -------------------------------------------------------------------------------- /test/util.mjs: -------------------------------------------------------------------------------- 1 | import { createHash } from 'node:crypto'; 2 | import { readdirSync, readFileSync } from 'node:fs'; 3 | 4 | export function compareMarkerFiles(dir, newDir = `${dir}-new`) { 5 | const markers = JSON.parse(readFile(`${dir}/markers.json`)); 6 | const newMarkers = JSON.parse(readFile(`${newDir}/markers.json`)); 7 | 8 | const maxIndex = Math.max(markers.length, newMarkers.length); 9 | 10 | for (let i = 0; i < maxIndex; i++) { 11 | if (!isMarkerEqual(markers[i], newMarkers[i])) { 12 | const markerJson = JSON.stringify(markers[i], null, 4); 13 | const newMarkerJson = JSON.stringify(newMarkers[i], null, 4); 14 | 15 | console.error(`Marker mismatch at index ${i}:`); 16 | console.info(`## EXPECTED (${dir}/markers.json)`); 17 | console.info(markerJson); 18 | console.info(`## ACTUAL (${newDir}/markers.json)`); 19 | console.info(newMarkerJson); 20 | return false; 21 | } 22 | } 23 | 24 | return true; 25 | } 26 | 27 | export function compareDir(dir, newDir = `${dir}-new`, extensions = ['png', 'bin']) { 28 | for (const file of readdirSync(dir)) { 29 | if (extensions.some(ext => file.endsWith(`.${ext}`))) { 30 | compare(`${dir}/${file}`, `${newDir}/${file}`); 31 | } 32 | } 33 | } 34 | 35 | export function readFile(file) { 36 | try { 37 | return readFileSync(file).toString(); 38 | } catch (e) { 39 | return null; 40 | } 41 | } 42 | 43 | function isMarkerEqual(markerA, markerB) { 44 | return markerA != null && markerB != null 45 | && markerA.description === markerB.description 46 | && markerA.icon === markerB.icon 47 | && markerA.x === markerB.x 48 | && markerA.y === markerB.y 49 | && markerA.z === markerB.z; 50 | } 51 | 52 | function compare(file1, file2) { 53 | const buffer1 = readFile(file1); 54 | if (!buffer1) { 55 | console.error(`Missing file ${file1}`); 56 | return false; 57 | } 58 | 59 | const buffer2 = readFile(file2); 60 | if (!buffer2) { 61 | console.error(`Missing file ${file2}`); 62 | return false; 63 | } 64 | 65 | const hash1 = md5(buffer1); 66 | const hash2 = md5(buffer2); 67 | 68 | if (hash1 !== hash2) { 69 | console.error(`MD5 mismatch: ${hash1} vs. ${hash2}`); 70 | 71 | const diffBytes = []; 72 | for (let i = 0; i < Math.max(buffer1.length, buffer2.length); i++) { 73 | const byte1 = buffer1[i]; 74 | const byte2 = buffer2[i]; 75 | 76 | if (byte1 !== byte2) { 77 | diffBytes.push([i, byte1, byte2]); 78 | 79 | if (diffBytes.length >= 5) { 80 | break; 81 | } 82 | } 83 | } 84 | 85 | for (const diffByte of diffBytes) { 86 | const [byteNumber, byte1, byte2] = diffByte; 87 | console.info(toHex(byteNumber, 8), toHex(byte1), toHex(byte2)); 88 | } 89 | 90 | return false; 91 | } 92 | 93 | return true; 94 | } 95 | 96 | function md5(buffer) { 97 | return createHash('md5') 98 | .update(buffer) 99 | .digest('hex'); 100 | } 101 | 102 | function toHex(byte, padding = 2) { 103 | return byte?.toString(16) 104 | .padStart(padding, '0') 105 | .toUpperCase() ?? '--'; 106 | } 107 | --------------------------------------------------------------------------------