Last active
April 25, 2025 22:39
-
-
Save Dissolutio/c7e8b75b58f75bec20f3538384de52aa to your computer and use it in GitHub Desktop.
JavaScript function reads a specific binary file format used by VirtualScape (a map editor for Heroscape). This is a Virtualscape file (.hsc) decoder.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
This function reads a specific binary file format used by VirtualScape. | |
VirtualScape map editor: https://github.com/didiers/virtualscape | |
*/ | |
export default function readVirtualscapeMapFile(file) { | |
return new Promise((resolve, reject) => { | |
const reader = new FileReader() | |
reader.onloadend = () => { | |
const arrayBuffer = reader.result | |
const virtualscapeMap = processVirtualscapeArrayBuffer(arrayBuffer) | |
resolve(virtualscapeMap) | |
} | |
reader.onerror = () => { | |
reject(reader.error) | |
} | |
reader.readAsArrayBuffer(file) | |
}) | |
} | |
const isLittleEndian = true | |
let offset = 0 // this will be mutated throughout the following function call, to track our current position in the file | |
function processVirtualscapeArrayBuffer(arrayBuffer) { | |
const dataView = new DataView(arrayBuffer) | |
offset = 0 | |
const virtualScapeMap = { | |
version: 0, | |
name: '', | |
author: '', | |
playerNumber: '', | |
scenario: '', | |
levelPerPage: 0, | |
printingTransparency: 0, | |
printingGrid: false, | |
printTileNumber: false, | |
printStartAreaAsLevel: true, | |
tileCount: 0, | |
tiles: [], | |
} | |
// file version "0.0007" only one ever seen | |
virtualScapeMap.version = getFloat64(dataView) | |
virtualScapeMap.name = readCString(dataView) | |
virtualScapeMap.author = readCString(dataView) | |
virtualScapeMap.playerNumber = readCString(dataView) | |
const scenarioLength = getInt32(dataView) | |
let scenarioRichText = '' | |
for (let i = 0; i < scenarioLength; i++) { | |
scenarioRichText += String.fromCharCode(getUint8(dataView)) | |
} | |
virtualScapeMap.scenario = rtfToText(scenarioRichText) | |
virtualScapeMap.levelPerPage = getInt32(dataView) | |
virtualScapeMap.printingTransparency = getInt32(dataView) | |
virtualScapeMap.printingGrid = getInt32(dataView) !== 0 | |
virtualScapeMap.printTileNumber = getInt32(dataView) !== 0 | |
virtualScapeMap.printStartAreaAsLevel = getInt32(dataView) !== 0 | |
virtualScapeMap.tileCount = getInt32(dataView) | |
for (let i = 0; i < virtualScapeMap.tileCount; i++) { | |
// if all C-string fields above were empty, then tiles start at byte 48 | |
const tile = { | |
type: 0, | |
version: 0.0003, | |
rotation: 0, | |
posX: 0, | |
posY: 0, | |
posZ: 0, | |
glyphLetter: '', | |
glyphName: '', | |
startName: '', | |
colorf: '', | |
figure: { | |
name: '', | |
name2: '', | |
}, | |
personal: { | |
pieceSize: 0, | |
textureTop: '', | |
textureSide: '', | |
letter: '', | |
name: '', | |
}, | |
} | |
// type designates a unique piece/tile in heroscape | |
tile.type = getInt32(dataView) | |
// tile version "0.0003" only one tested or seen | |
tile.version = getFloat64(dataView) | |
// 6 rotations progress clockwise, 0-5 | |
tile.rotation = getInt32(dataView) | |
// x,y are "odd-r" offset hex coordinates: https://www.redblobgames.com/grids/hexagons/#coordinates | |
tile.posX = getInt32(dataView) | |
tile.posY = getInt32(dataView) | |
// z is altitude in virtualscape | |
tile.posZ = getInt32(dataView) | |
// glyphLetter reliably is stored and read in files | |
tile.glyphLetter = String.fromCharCode(getUint8(dataView)) | |
// glyphName is empty on some tests of files, do not use it | |
tile.glyphName = readCString(dataView) | |
tile.startName = readCString(dataView) | |
const red = getUint8(dataView) | |
const green = getUint8(dataView) | |
const blue = getUint8(dataView) | |
const alpha = getUint8(dataView) | |
tile.colorf = `rgba(${red},${green},${blue},${alpha})` | |
if (Math.floor(tile.type / 1000) === 17) { | |
// "personal" tiles have additional data | |
tile.personal.pieceSize = getInt32(dataView) | |
tile.personal.textureTop = readCString(dataView) // My Ice.bmp | |
tile.personal.textureSide = readCString(dataView) // My IceSide.bmp | |
tile.personal.letter = readCString(dataView) | |
tile.personal.name = readCString(dataView) | |
} | |
if (Math.floor(tile.type / 1000) === 18) { | |
// "figure" tiles have additional data | |
tile.figure.name = readCString(dataView) | |
tile.figure.name2 = readCString(dataView) | |
} | |
virtualScapeMap.tiles.push(tile) | |
} | |
// sort by posZ, so we can build from the bottom up (posZ is altitude in virtualscape) | |
virtualScapeMap.tiles.sort((a, b) => { | |
return a.posZ - b.posZ | |
}) | |
return virtualScapeMap | |
} | |
function getFloat64(dataView) { | |
const val = dataView.getFloat64(offset, isLittleEndian) | |
offset += 8 | |
return val | |
} | |
function getInt32(dataView) { | |
const val = dataView.getInt32(offset, isLittleEndian) | |
offset += 4 | |
return val | |
} | |
function getUint32(dataView) { | |
const val = dataView.getUint32(offset, isLittleEndian) | |
offset += 4 | |
return val | |
} | |
function getInt16(dataView) { | |
const val = dataView.getInt16(offset, isLittleEndian) | |
offset += 2 | |
return val | |
} | |
function getUint16(dataView) { | |
const val = dataView.getUint16(offset, isLittleEndian) | |
offset += 2 | |
return val | |
} | |
function getUint8(dataView) { | |
const val = dataView.getUint8(offset) | |
offset += 1 | |
return val | |
} | |
function readCString(dataView) { | |
const length = readCStringLength(dataView) | |
let value = '' | |
for (let i = 0; i < length; i++) { | |
value += String.fromCodePoint(getInt16(dataView)) | |
} | |
return value | |
} | |
function readCStringLength(dataView) { | |
let length = 0 | |
const byte = getUint8(dataView) | |
if (byte !== 0xff) { | |
// Case 1: If the first byte is not 0xFF, it directly represents the length. | |
length = byte | |
} else { | |
// Case 2: If the first byte is 0xFF, read the next 2 bytes as a 16-bit unsigned integer. | |
const short = getUint16(dataView) | |
if (short === 0xfffe) { | |
// Case 2a: If the 16-bit value is 0xFFFE, recursively call `readCStringLength`. | |
// This indicates that the length is encoded in a more complex way. | |
return readCStringLength(dataView) | |
} else if (short === 0xffff) { | |
// Case 2b: If the 16-bit value is 0xFFFF, read the next 4 bytes as a 32-bit unsigned integer. | |
// This represents a very large string length. | |
length = getUint32(dataView) | |
} else { | |
// Case 2c: Otherwise, the 16-bit value itself represents the length. | |
length = short | |
} | |
} | |
return length | |
} | |
function rtfToText(rtf) { | |
// https://stackoverflow.com/questions/29922771/convert-rtf-to-and-from-plain-text | |
rtf = rtf.replace(/\\par[d]?/g, '') | |
return rtf | |
.replace(/\{\*?\\[^{}]+}|[{}]|\\\n?[A-Za-z]+\n?(?:-?\d+)?[ ]?/g, '') | |
.trim() | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
In regards to the readCStringLength:
Why It Works
Variable-Length Encoding:
The function supports multiple ways of encoding the string length:
A single byte (0x00 to 0xFE) for small lengths.
A 16-bit value (0x0000 to 0xFFFD) for medium lengths.
A 32-bit value for very large lengths (triggered by 0xFFFF).
Recursive Handling:
If the 16-bit value is 0xFFFE, the function recursively calls itself. This could be used for nested or extended encoding schemes.
Efficiency:
By using a single byte for small strings and larger encodings only when necessary, the function minimizes the size of the binary data.
Example Scenarios
Case 1: First byte is 0x05 → Length is 5.
Case 2a: First byte is 0xFF, next 2 bytes are 0xFFFE → Recursively decode the length.
Case 2b: First byte is 0xFF, next 2 bytes are 0xFFFF, next 4 bytes are 0x00000064 → Length is 100.
Case 2c: First byte is 0xFF, next 2 bytes are 0x0010 → Length is 16.
Why It's Needed
The .hsc file format likely uses this variable-length encoding to optimize storage for strings of different lengths. Small strings use less space, while large strings are supported with additional bytes. The recursive handling (0xFFFE) might be used for special cases or future-proofing the format.