Skip to content

Instantly share code, notes, and snippets.

@Dissolutio
Last active April 25, 2025 22:39
Show Gist options
  • Save Dissolutio/c7e8b75b58f75bec20f3538384de52aa to your computer and use it in GitHub Desktop.
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 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()
}
@Dissolutio
Copy link
Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment