498 lines
18 KiB
JavaScript
498 lines
18 KiB
JavaScript
const LE = true // little-endian
|
|
|
|
// JS bitmap format: array of scanlines, each scanline being an array of numbers from 0-3
|
|
export const emptyBitmap = (w, h, color = 0) => {
|
|
const bitmap = []
|
|
for (let y = 0; y < h; y ++) {
|
|
const scanline = []
|
|
for (let x = 0; x < w; x ++) {
|
|
scanline.push(color)
|
|
scanline.push(color)
|
|
scanline.push(color)
|
|
scanline.push(color)
|
|
}
|
|
bitmap.push(scanline)
|
|
}
|
|
return bitmap
|
|
}
|
|
|
|
export const drawByte = (bitmap, x, y, byte) => {
|
|
bitmap[y][x] = (byte & 0xc0) >> 6
|
|
bitmap[y][x + 1] = (byte & 0x30) >> 4
|
|
bitmap[y][x + 2] = (byte & 0x0c) >> 2
|
|
bitmap[y][x + 3] = (byte & 0x03)
|
|
}
|
|
|
|
const signedByte = (byte) => {
|
|
if ((byte & 0x80) != 0) {
|
|
const complement = (byte ^ 0xff) + 1
|
|
return -complement
|
|
} else {
|
|
return byte
|
|
}
|
|
}
|
|
|
|
const decodeHowHeld = (byte) => {
|
|
const heldVal = byte & 0xc0
|
|
if (heldVal == 0) {
|
|
return "swing"
|
|
} else if (heldVal == 0x40) {
|
|
return "out"
|
|
} else if (heldVal == 0x80) {
|
|
return "both"
|
|
} else {
|
|
return "at_side"
|
|
}
|
|
}
|
|
|
|
const encodeHowHeld = (howHeld) => {
|
|
if (howHeld == "swing") {
|
|
return 0x00
|
|
} else if (howHeld == "out") {
|
|
return 0x40
|
|
} else if (howHeld == "both") {
|
|
return 0x80
|
|
} else if (howHeld == "at_side") {
|
|
return 0xc0
|
|
} else {
|
|
throw new Error(`Unknown hold "${howHeld}"`)
|
|
}
|
|
}
|
|
|
|
const decodeCelType = (byte) => {
|
|
const typeVal = byte & 0xc0
|
|
if (typeVal == 0x00) {
|
|
if ((byte & 0x20) == 0) {
|
|
return "bitmap"
|
|
} else {
|
|
return "text"
|
|
}
|
|
} else if (typeVal == 0x40) {
|
|
return "trap"
|
|
} else if (typeVal == 0x80) {
|
|
return "box"
|
|
} else {
|
|
return "circle"
|
|
}
|
|
}
|
|
|
|
const encodeCelType = (type) => {
|
|
if (type == "bitmap") {
|
|
return 0x00
|
|
} else if (type == "text") {
|
|
return 0x20
|
|
} else if (type == "trap") {
|
|
return 0x40
|
|
} else if (type == "box") {
|
|
return 0x80
|
|
} else if (type == "circle") {
|
|
return 0xc0
|
|
} else {
|
|
throw new Error(`Unknown cel type "${type}"`)
|
|
}
|
|
}
|
|
|
|
const celDecoder = {}
|
|
const celEncoder = {}
|
|
|
|
celDecoder.bitmap = (data, cel) => {
|
|
// bitmap cells are RLE-encoded vertical strips of bytes. Decoding starts from the bottom-left
|
|
// and proceeds upwards until the top of the bitmap is hit; then then next vertical strip is decoded.
|
|
// Each byte describes four 2-bit pixels.
|
|
const bitmap = emptyBitmap(cel.width, cel.height)
|
|
let ibmp = 0
|
|
const end = cel.width * cel.height
|
|
const putByte = (byte) => {
|
|
const x = Math.floor(ibmp / cel.height) * 4
|
|
const y = (cel.height - (ibmp % cel.height)) - 1
|
|
drawByte(bitmap, x, y, byte)
|
|
ibmp ++
|
|
}
|
|
let i = 6
|
|
while (ibmp < end) {
|
|
const byte = data.getUint8(i)
|
|
i ++
|
|
if (byte == 0) {
|
|
// A zero byte denotes the start of a run of identical bytes. The second
|
|
// byte denotes the number of repetitions.
|
|
const count = data.getUint8(i)
|
|
i ++
|
|
if ((count & 0x80) == 0) {
|
|
// if the high bit of the count is not set, we read a third byte to
|
|
// determine the byte to repeat.
|
|
const val = data.getUint8(i)
|
|
i ++
|
|
for (let repeat = 0; repeat < count; repeat ++) {
|
|
putByte(val)
|
|
}
|
|
} else {
|
|
// if the high bit of the count is set, the lower 7 bits are used as
|
|
// the count, and a fully transparent byte is repeated.
|
|
for (let repeat = 0; repeat < (count & 0x7f); repeat ++) {
|
|
putByte(0)
|
|
}
|
|
}
|
|
} else {
|
|
// non-zero bytes are raw bitmap data
|
|
putByte(byte)
|
|
}
|
|
}
|
|
cel.bitmap = bitmap
|
|
}
|
|
|
|
celDecoder.box = (data, cel) => {
|
|
const bitmap = emptyBitmap(cel.width, cel.height)
|
|
cel.borderLR = (data.getUint8(0) & 0x20) != 0
|
|
cel.borderTB = (data.getUint8(0) & 0x10) != 0
|
|
cel.pattern = data.getUint8(6)
|
|
for (let y = 0; y < cel.height; y ++) {
|
|
for (let x = 0; x < cel.width; x ++) {
|
|
if (cel.borderTB && (y == 0 || y == (cel.height - 1))) {
|
|
drawByte(bitmap, x * 4, y, 0xaa)
|
|
} else {
|
|
drawByte(bitmap, x * 4, y, cel.pattern)
|
|
}
|
|
}
|
|
if (cel.borderLR) {
|
|
const line = bitmap[y]
|
|
line[0] = 2
|
|
line[line.length - 1] = 2
|
|
}
|
|
}
|
|
cel.bitmap = bitmap
|
|
}
|
|
|
|
const horizontalLine = (bitmap, xa, xb, y, patternByte) => {
|
|
const xStart = xa - (xa % 4)
|
|
const xEnd = (xb + (3 - (xb % 4))) - 3
|
|
for (let x = xStart + 4; x < xEnd; x += 4) {
|
|
drawByte(bitmap, x, y, patternByte)
|
|
}
|
|
const startBit = ((xa - xStart) * 2)
|
|
const startByte = (0xff >> startBit) & patternByte
|
|
drawByte(bitmap, xStart, y, startByte)
|
|
const endBit = (((xEnd + 3) - xb) * 2)
|
|
const endByte = (0xff << endBit) & patternByte
|
|
drawByte(bitmap, xEnd, y, endByte)
|
|
}
|
|
|
|
celDecoder.trap = (data, cel) => {
|
|
let border = false
|
|
// trap.m:21 - high-bit set means "draw a border"
|
|
// It looks like this was used as a flag and the real height
|
|
// was ORed with 0x80 - see house2.m, sign2.m
|
|
// There are also trapezoids that use 0x80 as their height -
|
|
// bwall6.m, bwall7.m, bwall9.m, magic_wall.m
|
|
// This appears to be special-cased to mean "no border" at trap.m:26
|
|
// mix.m:253 appears to have the logic to calculate y2, extracting
|
|
// the height by ANDing with 0x7f (when not 0x80)
|
|
if ((cel.height & 0x80) != 0 && cel.height != 0x80) {
|
|
border = true
|
|
cel.height = cel.height & 0x7f
|
|
}
|
|
if ((data.getUint8(0) & 0x10) == 0) {
|
|
// shape_pattern is a repeating 4-pixel colour, same as box
|
|
cel.pattern = data.getUint8(6)
|
|
} else {
|
|
// shape_pattern is 0xff, and the pattern is a bitmap that follows
|
|
// the trapezoid definition
|
|
// dline.m:103 - first two bytes are bitmasks used for efficiently calculating
|
|
// offsets into the texture. This means that the dimensions will be a power of
|
|
// two, and we can get the width and height simply by adding one to the mask.
|
|
const texW = data.getUint8(11) + 1
|
|
const texH = data.getUint8(12) + 1
|
|
cel.texture = emptyBitmap(texW, texH)
|
|
let i = 13
|
|
// dline.m:111 - the y position into the texture is calculated by
|
|
// ANDing y1 with the height mask; thus, unlike prop bitmaps, we decode
|
|
// from the top down
|
|
for (let y = 0; y < texH; y ++) {
|
|
for (let x = 0; x < texW; x ++) {
|
|
drawByte(cel.texture, x * 4, y, data.getUint8(i))
|
|
i ++
|
|
}
|
|
}
|
|
}
|
|
cel.x1a = data.getUint8(7)
|
|
cel.x1b = data.getUint8(8)
|
|
cel.x2a = data.getUint8(9)
|
|
cel.x2b = data.getUint8(10)
|
|
|
|
// trapezoid-drawing algorithm:
|
|
// draw_line: draws a line from x1a,y1 to x1b, y1
|
|
// handles border drawing (last/first line, edges)
|
|
// decreases vcount, then jumps to cycle1 if there
|
|
// are more lines
|
|
// cycle1: run bresenham, determine if x1a (left edge) needs to be incremented
|
|
// or decremented (self-modifying code! the instruction in inc_dec1 is
|
|
// written at trap.m:52)
|
|
// has logic to jump back to cycle1 if we have a sharp enough angle that
|
|
// we need to move more than one pixel horizontally
|
|
// cycle2: same thing, but for x2a (right edge)
|
|
// at the end, increments y1 and jumps back to the top of draw_line
|
|
cel.width = Math.floor((Math.max(cel.x1a, cel.x1b, cel.x2a, cel.x2b) + 3) / 4)
|
|
// trap.m:32 - delta_y and vcount are calculated by subtracting y2 - y1.
|
|
// mix.m:253: y2 is calculated as cel_y + cel_height
|
|
// mix.m:261: y1 is calculated as cel_y + 1
|
|
// So for a one-pixel tall trapezoid, deltay is 0, because y1 == y2.
|
|
// vcount is decremented until it reaches -1, compensating for the off-by-one.
|
|
const deltay = cel.height - 1
|
|
cel.bitmap = emptyBitmap(cel.width, cel.height)
|
|
const dxa = Math.abs(cel.x1a - cel.x2a)
|
|
const dxb = Math.abs(cel.x1b - cel.x2b)
|
|
const countMaxA = Math.max(dxa, deltay)
|
|
const countMaxB = Math.max(dxb, deltay)
|
|
const inca = cel.x1a < cel.x2a ? 1 : -1
|
|
const incb = cel.x1b < cel.x2b ? 1 : -1
|
|
let x1aLo = Math.floor(countMaxA / 2)
|
|
let y1aLo = x1aLo
|
|
let x1bLo = Math.floor(countMaxB / 2)
|
|
let y1bLo = x1bLo
|
|
let xa = cel.x1a
|
|
let xb = cel.x1b
|
|
for (let y = 0; y < cel.height; y ++) {
|
|
const line = cel.bitmap[y]
|
|
if (border && (y == 0 || y == (cel.height - 1))) {
|
|
// top and bottom border line
|
|
horizontalLine(cel.bitmap, xa, xb, y, 0xaa, true)
|
|
} else {
|
|
if (cel.texture) {
|
|
const texLine = cel.texture[y % cel.texture.length]
|
|
for (let x = xa; x <= xb; x ++) {
|
|
line[x] = texLine[x % texLine.length]
|
|
}
|
|
} else {
|
|
horizontalLine(cel.bitmap, xa, xb, y, cel.pattern, border)
|
|
}
|
|
}
|
|
|
|
if (border) {
|
|
line[xa] = 2
|
|
line[xb] = 2
|
|
}
|
|
|
|
// cycle1: move xa
|
|
do {
|
|
x1aLo += dxa
|
|
if (x1aLo >= countMaxA) {
|
|
x1aLo -= countMaxA
|
|
xa += inca
|
|
}
|
|
y1aLo += deltay
|
|
} while (y1aLo < countMaxA)
|
|
y1aLo -= countMaxA
|
|
|
|
// cycle2: move xb
|
|
do {
|
|
x1bLo += dxb
|
|
if (x1bLo >= countMaxB) {
|
|
x1bLo -= countMaxB
|
|
xb += incb
|
|
}
|
|
y1bLo += deltay
|
|
} while (y1bLo < countMaxA)
|
|
y1bLo -= countMaxA
|
|
}
|
|
}
|
|
|
|
const decodeCel = (data, changesColorRam) => {
|
|
const cel = {
|
|
data: data,
|
|
changesColorRam: changesColorRam,
|
|
type: decodeCelType(data.getUint8(0)),
|
|
// wild: (data.getUint8(0) & 0x10) == 0 ? "color" : "pattern",
|
|
width: data.getUint8(0) & 0x0f,
|
|
height: data.getUint8(1),
|
|
xOffset: data.getInt8(2),
|
|
yOffset: data.getInt8(3),
|
|
xRel: data.getInt8(4),
|
|
yRel: data.getInt8(5)
|
|
}
|
|
if (celDecoder[cel.type]) {
|
|
celDecoder[cel.type](data, cel)
|
|
}
|
|
return cel
|
|
}
|
|
|
|
const decodeSide = (byte) => {
|
|
const side = byte & 0x03
|
|
if (side == 0x00) {
|
|
return "left"
|
|
} else if (side == 0x01) {
|
|
return "right"
|
|
} else if (side == 0x02) {
|
|
return "up"
|
|
} else {
|
|
return "down"
|
|
}
|
|
}
|
|
const encodeSide = (side) => {
|
|
if (side == "left") {
|
|
return 0x00
|
|
} else if (side == "right") {
|
|
return 0x01
|
|
} else if (side == "up") {
|
|
return 0x02
|
|
} else if (side == "down") {
|
|
return 0x03
|
|
} else {
|
|
throw new Error(`Unknown side "${side}"`)
|
|
}
|
|
}
|
|
|
|
const decodeWalkto = (byte) => {
|
|
return { fromSide: decodeSide(byte), offset: signedByte(byte & 0xfc) }
|
|
}
|
|
|
|
const encodeWalkto = ({ fromSide, offset }) => {
|
|
return encodeSide(fromSide) | (offset & 0xfc)
|
|
}
|
|
|
|
const decodeAnimations = (data, startEndTableOff, firstCelOff, stateCount) => {
|
|
const animations = []
|
|
// The prop structure also does not encode a count for how many frames there are, so we simply
|
|
// stop parsing once we find one that doesn't make sense.
|
|
// We also use the heuristic that this structure always precedes the first cel, as that seems to be
|
|
// consistently be the case with all the props in the Habitat source tree. We'll stop reading
|
|
// animation data if we cross that boundary. If we encounter a prop that has the animation data
|
|
// _after_ the cel data, which would be legal but doesn't happen in practice, then we ignore this
|
|
// heuristic rather than failing to parse any animation data.
|
|
// It's possible for there to be no frames, which is represented by an offset of 0 (no_animation)
|
|
if (startEndTableOff != 0) {
|
|
for (let frameOff = startEndTableOff; (startEndTableOff > firstCelOff) || (frameOff < firstCelOff); frameOff += 2) {
|
|
// each animation is two bytes: the starting state, and the ending state
|
|
// the first byte can have its high bit set to indicate that the animation should cycle
|
|
const cycle = (data.getUint8(frameOff) & 0x80) != 0
|
|
const startState = data.getUint8(frameOff) & 0x7f
|
|
const endState = data.getUint8(frameOff + 1)
|
|
if (startState >= stateCount || endState >= stateCount) {
|
|
break
|
|
}
|
|
animations.push({ cycle: cycle, startState: startState, endState: endState })
|
|
}
|
|
}
|
|
return animations
|
|
}
|
|
|
|
export const decodeProp = (data) => {
|
|
const prop = {
|
|
data: data,
|
|
howHeld: decodeHowHeld(data.getUint8(0)),
|
|
colorBitmask: data.getUint8(1),
|
|
containerXYOff: data.getUint8(3), // TODO: parse this when nonzero
|
|
walkto: { left: decodeWalkto(data.getUint8(4)), right: decodeWalkto(data.getUint8(5)), yoff: data.getInt8(6) },
|
|
celmasks: [],
|
|
cels: []
|
|
}
|
|
const stateCount = (data.getUint8(0) & 0x3f) + 1
|
|
const graphicStateOff = data.getUint8(2)
|
|
const celMasksOff = 7
|
|
const celOffsetsOff = celMasksOff + stateCount
|
|
|
|
// The prop structure does not directly encode a count for how many cels there are, but each
|
|
// "graphic state" is defined by a bitmask marking which cels are present, and we do know how
|
|
// many states there are. We can assume that all cels are referenced by at least one state,
|
|
// and use that to determine the cel count.
|
|
let allCelsMask = 0
|
|
for (let icelmask = 0; icelmask < stateCount; icelmask ++) {
|
|
const celmask = data.getUint8(celMasksOff + icelmask)
|
|
prop.celmasks.push(celmask)
|
|
allCelsMask |= celmask
|
|
}
|
|
if (allCelsMask != 0x80 && allCelsMask != 0xc0 && allCelsMask != 0xe0 && allCelsMask != 0xf0 &&
|
|
allCelsMask != 0xf8 && allCelsMask != 0xfc && allCelsMask != 0xfe && allCelsMask != 0xff) {
|
|
throw new Error("Inconsistent graphic state cel masks - implies unused cel data")
|
|
}
|
|
let firstCelOff = Number.POSITIVE_INFINITY
|
|
for (let celOffsetOff = celOffsetsOff; allCelsMask != 0; celOffsetOff += 2) {
|
|
const icel = prop.cels.length
|
|
const celbit = 0x80 >> icel
|
|
const celOff = data.getUint16(celOffsetOff, LE)
|
|
firstCelOff = Math.min(celOff, firstCelOff)
|
|
prop.cels.push(decodeCel(new DataView(data.buffer, celOff), (prop.colorBitmask & celbit) != 0))
|
|
allCelsMask = (allCelsMask << 1) & 0xff
|
|
}
|
|
prop.animations = decodeAnimations(data, graphicStateOff, firstCelOff, stateCount)
|
|
return prop
|
|
}
|
|
|
|
const decodeLimb = (data, limb) => {
|
|
let frameCount = data.getUint8(0) + 1
|
|
limb.frames = []
|
|
for (let iframe = 0; iframe < frameCount; iframe ++) {
|
|
limb.frames.push(data.getInt8(3 + iframe))
|
|
}
|
|
const celOffsetsOff = 3 + frameCount
|
|
const maxCelIndex = Math.max(...limb.frames)
|
|
limb.cels = []
|
|
let firstCelOff
|
|
for (let icel = 0; icel <= maxCelIndex; icel ++) {
|
|
const celOff = data.getUint16(celOffsetsOff + (icel * 2), LE)
|
|
if (icel == 0) {
|
|
firstCelOff = celOff
|
|
}
|
|
limb.cels.push(decodeCel(new DataView(data.buffer, data.byteOffset + celOff)))
|
|
}
|
|
limb.animations = decodeAnimations(data, data.getUint8(2), firstCelOff, limb.frames.length)
|
|
}
|
|
|
|
export const choreographyActions = [
|
|
"init", "stand", "walk", "hand_back", "sit_floor", "sit_chair", "bend_over",
|
|
"bend_back", "point", "throw", "get_shot", "jump", "punch", "wave",
|
|
"frown", "stand_back", "walk_front", "walk_back", "stand_front",
|
|
"unpocket", "gimme", "knife", "arm_get", "hand_out", "operate",
|
|
"arm_back", "shoot1", "shoot2", "nop", "sit_front"
|
|
]
|
|
|
|
export const decodeBody = (data) => {
|
|
const body = {
|
|
data: data,
|
|
headCelNumber: data.getUint8(19),
|
|
frozenWhenStands: data.getUint8(20),
|
|
frontFacingLimbOrder: [],
|
|
backFacingLimbOrder: [],
|
|
limbs: [],
|
|
choreography: [],
|
|
actions: {}
|
|
}
|
|
for (let ilimb = 0; ilimb < 6; ilimb ++) {
|
|
body.frontFacingLimbOrder.push(data.getUint8(27 + ilimb))
|
|
body.backFacingLimbOrder.push(data.getUint8(33 + ilimb))
|
|
const limb = {
|
|
pattern: data.getUint8(21 + ilimb),
|
|
affectedByHeight: data.getUint8(39 + ilimb)
|
|
}
|
|
const limbOff = data.getUint16(7 + (ilimb * 2), LE)
|
|
decodeLimb(new DataView(data.buffer, limbOff), limb)
|
|
body.limbs.push(limb)
|
|
}
|
|
const choreographyIndexOff = data.getUint16(0, LE)
|
|
const choreographyTableOff = data.getUint16(2, LE)
|
|
const indexToChoreography = new Map()
|
|
for (const [i, action] of choreographyActions.entries()) {
|
|
let tableIndex = data.getUint8(choreographyIndexOff + i)
|
|
let choreographyIndex = indexToChoreography.get(tableIndex)
|
|
if (choreographyIndex == undefined) {
|
|
choreographyIndex = body.choreography.length
|
|
indexToChoreography.set(tableIndex, choreographyIndex)
|
|
const choreography = []
|
|
body.choreography.push(choreography)
|
|
for (;; tableIndex ++) {
|
|
const state = data.getUint8(choreographyTableOff + tableIndex)
|
|
let limb = (state & 0x70) >> 4
|
|
let animation = state & 0x0f
|
|
if (limb == 6) {
|
|
limb = 5
|
|
animation += 0x10
|
|
}
|
|
choreography.push({ limb, animation })
|
|
if ((state & 0x80) != 0) {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
body.actions[action] = choreographyIndex
|
|
}
|
|
return body
|
|
}
|