font update, video sprite (TAV)

This commit is contained in:
minjaesong
2026-03-15 13:26:05 +09:00
parent db3a1f9a39
commit 1a0b754cde
9 changed files with 2014 additions and 4 deletions

View File

@@ -30,8 +30,6 @@ This process assumes that the game does NOT use the Java 9+ modules and every si
The Linux Aarch64 runtime must be prepared on the actual ARM Linux session.
Copy the runtimes to your workstation, rename the `bin/java` into `bin/Terrarum`, then `chmod -R +x` all of them.
### Packaging
Create an output directory if there is none (project root/buildapp/out)

Binary file not shown.

View File

@@ -0,0 +1,119 @@
package net.torvald.spriteanimation
import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.graphics.Texture
import com.badlogic.gdx.graphics.g2d.SpriteBatch
import com.jme3.math.FastMath
import net.torvald.terrarum.Second
import net.torvald.terrarum.gameactors.ActorWithBody
import net.torvald.terrarum.tav.AudioBankTav
import net.torvald.terrarum.tav.TavDecoder
import java.io.InputStream
/**
* A SpriteAnimation that plays a TAV video file.
*
* Usage:
* val anim = VideoSpriteAnimation(actor, stream, looping = true)
* actor.sprite = anim
* anim.start()
* // Optionally route audio:
* anim.audioBank?.let { bank ->
* val track = App.audioMixer.getFreeTrackNoMatterWhat()
* track.currentTrack = bank
* track.play()
* }
*/
class VideoSpriteAnimation(
parentActor: ActorWithBody,
tavStream: InputStream,
val looping: Boolean = false
) : SpriteAnimation(parentActor) {
val decoder = TavDecoder(tavStream, looping)
val audioBank: AudioBankTav? = if (decoder.hasAudio) AudioBankTav(decoder) else null
val cellWidth: Int get() = decoder.videoWidth
val cellHeight: Int get() = decoder.videoHeight
override val currentDelay: Second get() = 1f / decoder.fps.coerceAtLeast(1)
private var currentTexture: Texture? = null
private var deltaAccumulator = 0f
private var started = false
val isFinished: Boolean get() = decoder.isFinished.get()
fun start() {
decoder.start()
started = true
}
fun stop() {
decoder.stop()
started = false
}
// update() is a no-op: frame timing is handled in render() via frameDelta
override fun update(delta: Float) {}
override fun render(
frameDelta: Float,
batch: SpriteBatch,
posX: Float,
posY: Float,
scale: Float,
mode: Int,
forcedColourFilter: Color?
) {
if (!started) return
// Advance frame timing
deltaAccumulator += frameDelta
while (deltaAccumulator >= currentDelay) {
decoder.advanceFrame()
deltaAccumulator -= currentDelay
}
val pixmap = decoder.getFramePixmap() ?: return
// Dispose old texture and create new one from the current decoded Pixmap
currentTexture?.dispose()
currentTexture = Texture(pixmap).also {
it.setFilter(Texture.TextureFilter.Nearest, Texture.TextureFilter.Nearest)
}
batch.color = forcedColourFilter ?: colourFilter
val w = cellWidth
val h = cellHeight
val tx = (parentActor.hitboxTranslateX) * scale
val txF = (parentActor.hitboxTranslateX + parentActor.baseHitboxW) * scale
val ty = (parentActor.hitboxTranslateY + (h - parentActor.baseHitboxH)) * scale
val tyF = (parentActor.hitboxTranslateY + parentActor.baseHitboxH) * scale
val tex = currentTexture!!
val x0 = FastMath.floor(posX).toFloat()
val y0 = FastMath.floor(posY).toFloat()
val fw = FastMath.floor(w * scale).toFloat()
val fh = FastMath.floor(h * scale).toFloat()
if (flipHorizontal && flipVertical) {
batch.draw(tex, x0 + txF, y0 + tyF, -fw, -fh)
} else if (flipHorizontal && !flipVertical) {
batch.draw(tex, x0 + txF, y0 - ty, -fw, fh)
} else if (!flipHorizontal && flipVertical) {
batch.draw(tex, x0 - tx, y0 + tyF, fw, -fh)
} else {
batch.draw(tex, x0 - tx, y0 - ty, fw, fh)
}
}
override fun dispose() {
stop()
decoder.dispose()
currentTexture?.dispose()
currentTexture = null
}
}

View File

@@ -0,0 +1,36 @@
package net.torvald.terrarum.tav
import net.torvald.terrarum.audio.AudioBank
/**
* AudioBank adapter wrapping a TavDecoder's audio ring buffer.
* Reports 32000 Hz sampling rate; the audio pipeline resamples to 48000 Hz automatically.
* Lifecycle is managed by VideoSpriteAnimation — dispose() is a no-op here.
*/
class AudioBankTav(
private val decoder: TavDecoder,
override var songFinishedHook: (AudioBank) -> Unit = {}
) : AudioBank() {
override val notCopyable = true
override val name = "tav-audio"
/** TAD native sample rate; AudioProcessBuf resamples to 48000 Hz. */
override var samplingRate = 32000f
override var channels = 2
override var totalSizeInSamples: Long =
decoder.totalFrames * (32000L / decoder.fps.coerceAtLeast(1))
override fun readSamples(bufferL: FloatArray, bufferR: FloatArray): Int =
decoder.readAudioSamples(bufferL, bufferR)
override fun currentPositionInSamples(): Long = decoder.audioReadPos.get()
override fun reset() { /* reset is handled at decoder level by VideoSpriteAnimation */ }
override fun makeCopy(): AudioBank = throw UnsupportedOperationException("AudioBankTav is not copyable")
/** Lifecycle managed by VideoSpriteAnimation; do not dispose the decoder here. */
override fun dispose() {}
}

View File

@@ -0,0 +1,276 @@
package net.torvald.terrarum.tav
/**
* Shared DWT (Discrete Wavelet Transform) utility functions.
* Provides inverse CDF 9/7, CDF 5/3, and Haar transforms used by both
* video and audio decoders.
*
* Ported from GraphicsJSR223Delegate.kt and AudioAdapter.kt in the TSVM project.
*/
object DwtUtil {
// CDF 9/7 lifting constants
private const val ALPHA = -1.586134342f
private const val BETA = -0.052980118f
private const val GAMMA = 0.882911076f
private const val DELTA = 0.443506852f
private const val K = 1.230174105f
// -------------------------------------------------------------------------
// 1D Transforms
// -------------------------------------------------------------------------
/**
* Single-level 1D CDF 9/7 inverse lifting transform.
* Layout: first half = low-pass coefficients, second half = high-pass.
*/
fun inverse1D(data: FloatArray, length: Int) {
if (length < 2) return
val temp = FloatArray(length)
val half = (length + 1) / 2
for (i in 0 until half) {
temp[i] = data[i]
}
for (i in 0 until length / 2) {
if (half + i < length) temp[half + i] = data[half + i]
}
// Step 1: Undo scaling
for (i in 0 until half) temp[i] /= K
for (i in 0 until length / 2) {
if (half + i < length) temp[half + i] *= K
}
// Step 2: Undo delta update
for (i in 0 until half) {
val dCurr = if (half + i < length) temp[half + i] else 0.0f
val dPrev = if (i > 0 && half + i - 1 < length) temp[half + i - 1] else dCurr
temp[i] -= DELTA * (dCurr + dPrev)
}
// Step 3: Undo gamma predict
for (i in 0 until length / 2) {
if (half + i < length) {
val sCurr = temp[i]
val sNext = if (i + 1 < half) temp[i + 1] else sCurr
temp[half + i] -= GAMMA * (sCurr + sNext)
}
}
// Step 4: Undo beta update
for (i in 0 until half) {
val dCurr = if (half + i < length) temp[half + i] else 0.0f
val dPrev = if (i > 0 && half + i - 1 < length) temp[half + i - 1] else dCurr
temp[i] -= BETA * (dCurr + dPrev)
}
// Step 5: Undo alpha predict
for (i in 0 until length / 2) {
if (half + i < length) {
val sCurr = temp[i]
val sNext = if (i + 1 < half) temp[i + 1] else sCurr
temp[half + i] -= ALPHA * (sCurr + sNext)
}
}
// Interleave reconstruction
for (i in 0 until length) {
if (i % 2 == 0) {
data[i] = temp[i / 2]
} else {
val idx = i / 2
data[i] = if (half + idx < length) temp[half + idx] else 0.0f
}
}
}
/**
* Multi-level 1D CDF 9/7 inverse transform.
* Uses exact forward-transform lengths in reverse to handle non-power-of-2 sizes.
*/
fun inverseMultilevel1D(data: FloatArray, length: Int, levels: Int) {
val lengths = IntArray(levels + 1)
lengths[0] = length
for (i in 1..levels) lengths[i] = (lengths[i - 1] + 1) / 2
for (level in levels - 1 downTo 0) {
inverse1D(data, lengths[level])
}
}
/**
* Single-level 2D CDF 9/7 inverse transform.
* Column inverse first, then row inverse (matching encoder's row-then-column forward order).
*/
fun inverse2D(data: FloatArray, width: Int, height: Int, currentWidth: Int, currentHeight: Int) {
val maxSize = maxOf(width, height)
val tempBuf = FloatArray(maxSize)
// Column inverse transform (vertical)
for (x in 0 until currentWidth) {
for (y in 0 until currentHeight) tempBuf[y] = data[y * width + x]
inverse1D(tempBuf, currentHeight)
for (y in 0 until currentHeight) data[y * width + x] = tempBuf[y]
}
// Row inverse transform (horizontal)
for (y in 0 until currentHeight) {
for (x in 0 until currentWidth) tempBuf[x] = data[y * width + x]
inverse1D(tempBuf, currentWidth)
for (x in 0 until currentWidth) data[y * width + x] = tempBuf[x]
}
}
/**
* Multi-level 2D CDF 9/7 inverse transform.
* Uses exact forward-transform dimension sequences.
*/
fun inverseMultilevel2D(data: FloatArray, width: Int, height: Int, levels: Int,
filterType: Int = 1) {
val widths = IntArray(levels + 1)
val heights = IntArray(levels + 1)
widths[0] = width
heights[0] = height
for (i in 1..levels) {
widths[i] = (widths[i - 1] + 1) / 2
heights[i] = (heights[i - 1] + 1) / 2
}
val maxSize = maxOf(width, height)
val tempBuf = FloatArray(maxSize)
for (level in levels - 1 downTo 0) {
val cw = widths[level]
val ch = heights[level]
if (cw < 1 || ch < 1 || (cw == 1 && ch == 1)) continue
// Column inverse
for (x in 0 until cw) {
for (y in 0 until ch) tempBuf[y] = data[y * width + x]
applyInverse1DByFilter(tempBuf, ch, filterType)
for (y in 0 until ch) data[y * width + x] = tempBuf[y]
}
// Row inverse
for (y in 0 until ch) {
for (x in 0 until cw) tempBuf[x] = data[y * width + x]
applyInverse1DByFilter(tempBuf, cw, filterType)
for (x in 0 until cw) data[y * width + x] = tempBuf[x]
}
}
}
private fun applyInverse1DByFilter(data: FloatArray, length: Int, filterType: Int) {
when (filterType) {
0 -> dwt53Inverse1D(data, length)
1 -> inverse1D(data, length)
255 -> haarInverse1D(data, length)
else -> inverse1D(data, length)
}
}
// -------------------------------------------------------------------------
// Haar 1D Inverse
// -------------------------------------------------------------------------
fun haarInverse1D(data: FloatArray, length: Int) {
if (length < 2) return
val temp = FloatArray(length)
val half = (length + 1) / 2
for (i in 0 until half) {
if (2 * i + 1 < length) {
temp[2 * i] = data[i] + data[half + i]
temp[2 * i + 1] = data[i] - data[half + i]
} else {
temp[2 * i] = data[i]
}
}
for (i in 0 until length) data[i] = temp[i]
}
// -------------------------------------------------------------------------
// CDF 5/3 1D Inverse
// -------------------------------------------------------------------------
fun dwt53Inverse1D(data: FloatArray, length: Int) {
if (length < 2) return
val temp = FloatArray(length)
val half = (length + 1) / 2
System.arraycopy(data, 0, temp, 0, length)
// Undo update step (low-pass)
for (i in 0 until half) {
val update = 0.25f * ((if (i > 0) temp[half + i - 1] else 0.0f) +
(if (i < half - 1) temp[half + i] else 0.0f))
temp[i] -= update
}
// Undo predict step and interleave
for (i in 0 until half) {
data[2 * i] = temp[i]
val idx = 2 * i + 1
if (idx < length) {
val pred = 0.5f * (temp[i] + (if (i < half - 1) temp[i + 1] else temp[i]))
data[idx] = temp[half + i] + pred
}
}
}
// -------------------------------------------------------------------------
// Temporal inverse DWT (used for GOP decode)
// -------------------------------------------------------------------------
/**
* Apply inverse temporal 1D DWT using Haar or CDF 5/3.
* @param temporalMotionCoder 0=Haar, 1=CDF 5/3
*/
fun temporalInverse1D(data: FloatArray, numFrames: Int, temporalMotionCoder: Int = 0) {
if (numFrames < 2) return
if (temporalMotionCoder == 0) haarInverse1D(data, numFrames)
else dwt53Inverse1D(data, numFrames)
}
/**
* Apply inverse 3D DWT to GOP data (spatial inverse first, then temporal inverse).
*/
fun inverseMultilevel3D(
gopData: Array<FloatArray>,
width: Int, height: Int, numFrames: Int,
spatialLevels: Int, temporalLevels: Int,
spatialFilter: Int = 1, temporalMotionCoder: Int = 0
) {
// Step 1: Inverse spatial 2D DWT on each temporal frame
for (t in 0 until numFrames) {
inverseMultilevel2D(gopData[t], width, height, spatialLevels, spatialFilter)
}
if (numFrames < 2) return
// Step 2: Inverse temporal DWT at each spatial location
val temporalLengths = IntArray(temporalLevels + 1)
temporalLengths[0] = numFrames
for (i in 1..temporalLevels) temporalLengths[i] = (temporalLengths[i - 1] + 1) / 2
val temporalLine = FloatArray(numFrames)
for (y in 0 until height) {
for (x in 0 until width) {
val pixelIdx = y * width + x
for (t in 0 until numFrames) temporalLine[t] = gopData[t][pixelIdx]
for (level in temporalLevels - 1 downTo 0) {
val levelFrames = temporalLengths[level]
if (levelFrames >= 2) temporalInverse1D(temporalLine, levelFrames, temporalMotionCoder)
}
for (t in 0 until numFrames) gopData[t][pixelIdx] = temporalLine[t]
}
}
}
}

View File

@@ -0,0 +1,248 @@
package net.torvald.terrarum.tav
/**
* EZBC (Embedded Zero Block Coding) entropy decoder.
* Provides both 2D (video) and 1D (audio) variants.
*
* Ported from GraphicsJSR223Delegate.kt and AudioAdapter.kt in the TSVM project.
*/
object EzbcDecode {
// -------------------------------------------------------------------------
// Shared bitstream reader
// -------------------------------------------------------------------------
private class BitstreamReader(private val data: ByteArray, private val startOffset: Int, private val size: Int) {
private var bytePos = startOffset
private var bitPos = 0
private val endPos = startOffset + size
fun readBit(): Int {
if (bytePos >= endPos) return 0
val bit = (data[bytePos].toInt() shr bitPos) and 1
bitPos++
if (bitPos == 8) { bitPos = 0; bytePos++ }
return bit
}
fun readBits(numBits: Int): Int {
var value = 0
for (i in 0 until numBits) value = value or (readBit() shl i)
return value
}
fun bytesConsumed(): Int = (bytePos - startOffset) + if (bitPos > 0) 1 else 0
}
// -------------------------------------------------------------------------
// 2D EZBC decode (video coefficients, ShortArray output)
// -------------------------------------------------------------------------
/**
* Decode a single EZBC channel (2D variant for video).
* Header: 8-bit MSB bitplane, 16-bit width, 16-bit height.
*/
fun decode2DChannel(ezbcData: ByteArray, offset: Int, size: Int, outputCoeffs: ShortArray) {
val bs = BitstreamReader(ezbcData, offset, size)
val msbBitplane = bs.readBits(8)
val width = bs.readBits(16)
val height = bs.readBits(16)
if (width * height != outputCoeffs.size) {
System.err.println("[EZBC-2D] Dimension mismatch: ${width}x${height} != ${outputCoeffs.size}")
return
}
outputCoeffs.fill(0)
val significant = BooleanArray(outputCoeffs.size)
data class Block(val x: Int, val y: Int, val w: Int, val h: Int)
var insignificantQueue = ArrayList<Block>()
var nextInsignificant = ArrayList<Block>()
var significantQueue = ArrayList<Block>()
var nextSignificant = ArrayList<Block>()
insignificantQueue.add(Block(0, 0, width, height))
fun processSignificantBlockRecursive(block: Block, bitplane: Int, threshold: Int) {
if (block.w == 1 && block.h == 1) {
val idx = block.y * width + block.x
val signBit = bs.readBit()
outputCoeffs[idx] = (if (signBit == 1) -threshold else threshold).toShort()
significant[idx] = true
nextSignificant.add(block)
return
}
var midX = block.w / 2; if (midX == 0) midX = 1
var midY = block.h / 2; if (midY == 0) midY = 1
// Top-left
val tl = Block(block.x, block.y, midX, midY)
if (bs.readBit() == 1) processSignificantBlockRecursive(tl, bitplane, threshold)
else nextInsignificant.add(tl)
// Top-right
if (block.w > midX) {
val tr = Block(block.x + midX, block.y, block.w - midX, midY)
if (bs.readBit() == 1) processSignificantBlockRecursive(tr, bitplane, threshold)
else nextInsignificant.add(tr)
}
// Bottom-left
if (block.h > midY) {
val bl = Block(block.x, block.y + midY, midX, block.h - midY)
if (bs.readBit() == 1) processSignificantBlockRecursive(bl, bitplane, threshold)
else nextInsignificant.add(bl)
}
// Bottom-right
if (block.w > midX && block.h > midY) {
val br = Block(block.x + midX, block.y + midY, block.w - midX, block.h - midY)
if (bs.readBit() == 1) processSignificantBlockRecursive(br, bitplane, threshold)
else nextInsignificant.add(br)
}
}
for (bitplane in msbBitplane downTo 0) {
val threshold = 1 shl bitplane
for (block in insignificantQueue) {
if (bs.readBit() == 0) nextInsignificant.add(block)
else processSignificantBlockRecursive(block, bitplane, threshold)
}
for (block in significantQueue) {
val idx = block.y * width + block.x
if (bs.readBit() == 1) {
val bitValue = 1 shl bitplane
if (outputCoeffs[idx] < 0) outputCoeffs[idx] = (outputCoeffs[idx] - bitValue).toShort()
else outputCoeffs[idx] = (outputCoeffs[idx] + bitValue).toShort()
}
nextSignificant.add(block)
}
insignificantQueue = nextInsignificant; nextInsignificant = ArrayList()
significantQueue = nextSignificant; nextSignificant = ArrayList()
}
}
/**
* Decode all channels from an EZBC block.
* Format: [size_y(4)][ezbc_y][size_co(4)][ezbc_co][size_cg(4)][ezbc_cg]...
*/
fun decode2D(
compressedData: ByteArray, offset: Int,
channelLayout: Int,
outputY: ShortArray?, outputCo: ShortArray?, outputCg: ShortArray?, outputAlpha: ShortArray?
) {
val hasY = (channelLayout and 4) == 0
val hasCoCg = (channelLayout and 2) == 0
val hasAlpha = (channelLayout and 1) != 0
var ptr = offset
fun readSize(): Int {
val b0 = compressedData[ptr ].toInt() and 0xFF
val b1 = compressedData[ptr+1].toInt() and 0xFF
val b2 = compressedData[ptr+2].toInt() and 0xFF
val b3 = compressedData[ptr+3].toInt() and 0xFF
return b0 or (b1 shl 8) or (b2 shl 16) or (b3 shl 24)
}
if (hasY && outputY != null) {
val sz = readSize(); ptr += 4
decode2DChannel(compressedData, ptr, sz, outputY); ptr += sz
}
if (hasCoCg && outputCo != null) {
val sz = readSize(); ptr += 4
decode2DChannel(compressedData, ptr, sz, outputCo); ptr += sz
}
if (hasCoCg && outputCg != null) {
val sz = readSize(); ptr += 4
decode2DChannel(compressedData, ptr, sz, outputCg); ptr += sz
}
if (hasAlpha && outputAlpha != null) {
val sz = readSize(); ptr += 4
decode2DChannel(compressedData, ptr, sz, outputAlpha); ptr += sz
}
}
// -------------------------------------------------------------------------
// 1D EZBC decode (audio coefficients, ByteArray output)
// -------------------------------------------------------------------------
/**
* Decode a single EZBC channel (1D variant for TAD audio).
* Header: 8-bit MSB bitplane, 16-bit coefficient count.
* @return number of bytes consumed from [input]
*/
fun decode1DChannel(input: ByteArray, inputOffset: Int, inputSize: Int, coeffs: ByteArray): Int {
val bs = BitstreamReader(input, inputOffset, inputSize)
val msbBitplane = bs.readBits(8)
val count = bs.readBits(16)
coeffs.fill(0)
data class Block(val start: Int, val length: Int)
val states = BooleanArray(count) // significant flags
var insignificantQueue = ArrayList<Block>()
var nextInsignificant = ArrayList<Block>()
var significantQueue = ArrayList<Block>()
var nextSignificant = ArrayList<Block>()
insignificantQueue.add(Block(0, count))
fun processSignificantBlockRecursive(block: Block, bitplane: Int) {
if (block.length == 1) {
val idx = block.start
val signBit = bs.readBit()
val absVal = 1 shl bitplane
coeffs[idx] = (if (signBit != 0) -absVal else absVal).toByte()
states[idx] = true
nextSignificant.add(block)
return
}
val mid = maxOf(1, block.length / 2)
val left = Block(block.start, mid)
if (bs.readBit() != 0) processSignificantBlockRecursive(left, bitplane)
else nextInsignificant.add(left)
if (block.length > mid) {
val right = Block(block.start + mid, block.length - mid)
if (bs.readBit() != 0) processSignificantBlockRecursive(right, bitplane)
else nextInsignificant.add(right)
}
}
for (bitplane in msbBitplane downTo 0) {
for (block in insignificantQueue) {
if (bs.readBit() == 0) nextInsignificant.add(block)
else processSignificantBlockRecursive(block, bitplane)
}
for (block in significantQueue) {
val idx = block.start
if (bs.readBit() != 0) {
val sign = if (coeffs[idx] < 0) -1 else 1
val absVal = kotlin.math.abs(coeffs[idx].toInt())
coeffs[idx] = (sign * (absVal or (1 shl bitplane))).toByte()
}
nextSignificant.add(block)
}
insignificantQueue = nextInsignificant; nextInsignificant = ArrayList()
significantQueue = nextSignificant; nextSignificant = ArrayList()
}
return bs.bytesConsumed()
}
}

View File

@@ -0,0 +1,192 @@
package net.torvald.terrarum.tav
import io.airlift.compress.zstd.ZstdInputStream
import java.io.ByteArrayInputStream
/**
* TAD (TSVM Advanced Audio) decoder.
* Decodes TAD chunks to Float32 stereo PCM at 32000 Hz.
*
* Ported from AudioAdapter.kt in the TSVM project.
*/
object TadDecode {
// Coefficient scalars per subband (LL + 9 H bands, index 0=LL, 1-9=H bands L9..L1)
private val COEFF_SCALARS = floatArrayOf(
64.0f, 45.255f, 32.0f, 22.627f, 16.0f, 11.314f, 8.0f, 5.657f, 4.0f, 2.828f
)
// Base quantiser weight table: [channel 0=Mid][channel 1=Side]
private val BASE_QUANTISER_WEIGHTS = arrayOf(
floatArrayOf(4.0f, 2.0f, 1.8f, 1.6f, 1.4f, 1.2f, 1.0f, 1.0f, 1.3f, 2.0f), // Mid
floatArrayOf(6.0f, 5.0f, 2.6f, 2.4f, 1.8f, 1.3f, 1.0f, 1.0f, 1.6f, 3.2f) // Side
)
private const val LAMBDA_FIXED = 6.0f
private const val DWT_LEVELS = 9
/**
* Cross-chunk persistent state for the TAD de-emphasis IIR filter.
*/
class TadDecoderState {
var prevYL: Float = 0.0f
var prevYR: Float = 0.0f
}
// -------------------------------------------------------------------------
// Full TAD chunk decode
// -------------------------------------------------------------------------
/**
* Decode a single TAD chunk payload.
* Returns Pair(leftSamples, rightSamples) as Float32 in [-1, 1].
*
* @param payload Zstd-compressed TAD chunk payload
* @param sampleCount samples per channel
* @param maxIndex max quantiser index
* @param state persistent de-emphasis state (mutated in-place)
*/
fun decodeTadChunk(
payload: ByteArray,
sampleCount: Int,
maxIndex: Int,
state: TadDecoderState
): Pair<FloatArray, FloatArray> {
// Step 1: Zstd decompress
val decompressed = ZstdInputStream(ByteArrayInputStream(payload)).use { it.readBytes() }
// Step 2: EZBC 1D decode Mid and Side channels
val quantMid = ByteArray(sampleCount)
val quantSide = ByteArray(sampleCount)
val midBytesConsumed = EzbcDecode.decode1DChannel(decompressed, 0, decompressed.size, quantMid)
EzbcDecode.decode1DChannel(
decompressed, midBytesConsumed, decompressed.size - midBytesConsumed, quantSide
)
// Step 3 & 4: Lambda decompanding + dequantise
val dwtMid = FloatArray(sampleCount)
val dwtSide = FloatArray(sampleCount)
dequantiseCoeffs(0, quantMid, dwtMid, sampleCount, maxIndex)
dequantiseCoeffs(1, quantSide, dwtSide, sampleCount, maxIndex)
// Step 5: Inverse CDF 9/7 DWT (9 levels)
DwtUtil.inverseMultilevel1D(dwtMid, sampleCount, DWT_LEVELS)
DwtUtil.inverseMultilevel1D(dwtSide, sampleCount, DWT_LEVELS)
// Step 6: M/S to L/R
val left = FloatArray(sampleCount)
val right = FloatArray(sampleCount)
msToLR(dwtMid, dwtSide, left, right, sampleCount)
// Step 7: Gamma expansion
gammaExpand(left, right, sampleCount)
// Step 8: De-emphasis IIR (persistent state)
deemphasis(left, right, sampleCount, state)
return Pair(left, right)
}
// -------------------------------------------------------------------------
// PCM fallback decoders
// -------------------------------------------------------------------------
/** Decode Zstd-compressed interleaved PCMu8 stereo. Returns Float32 L/R. */
fun decodePcm8(payload: ByteArray): Pair<FloatArray, FloatArray> {
val decompressed = ZstdInputStream(ByteArrayInputStream(payload)).use { it.readBytes() }
val sampleCount = decompressed.size / 2
val left = FloatArray(sampleCount)
val right = FloatArray(sampleCount)
for (i in 0 until sampleCount) {
val l = (decompressed[i * 2 ].toInt() and 0xFF) - 128
val r = (decompressed[i * 2 + 1].toInt() and 0xFF) - 128
left[i] = l / 128.0f
right[i] = r / 128.0f
}
return Pair(left, right)
}
/** Decode Zstd-compressed interleaved PCM16-LE stereo. Returns Float32 L/R. */
fun decodePcm16(payload: ByteArray): Pair<FloatArray, FloatArray> {
val decompressed = ZstdInputStream(ByteArrayInputStream(payload)).use { it.readBytes() }
val sampleCount = decompressed.size / 4
val left = FloatArray(sampleCount)
val right = FloatArray(sampleCount)
for (i in 0 until sampleCount) {
val lLo = decompressed[i * 4 ].toInt() and 0xFF
val lHi = decompressed[i * 4 + 1].toInt()
val rLo = decompressed[i * 4 + 2].toInt() and 0xFF
val rHi = decompressed[i * 4 + 3].toInt()
left[i] = ((lHi shl 8) or lLo).toShort() / 32768.0f
right[i] = ((rHi shl 8) or rLo).toShort() / 32768.0f
}
return Pair(left, right)
}
// -------------------------------------------------------------------------
// Internal pipeline stages
// -------------------------------------------------------------------------
private fun lambdaDecompand(quantVal: Byte, maxIndex: Int): Float {
if (quantVal == 0.toByte()) return 0.0f
val sign = if (quantVal < 0) -1 else 1
var absIndex = kotlin.math.abs(quantVal.toInt()).coerceAtMost(maxIndex)
val normalisedCdf = absIndex.toFloat() / maxIndex
val cdf = 0.5f + normalisedCdf * 0.5f
var absVal = -(1.0f / LAMBDA_FIXED) * kotlin.math.ln(2.0f * (1.0f - cdf))
absVal = absVal.coerceIn(0.0f, 1.0f)
return sign * absVal
}
private fun dequantiseCoeffs(
channel: Int, quantised: ByteArray, coeffs: FloatArray,
count: Int, maxIndex: Int
) {
val firstBandSize = count shr DWT_LEVELS
val sidebandStarts = IntArray(DWT_LEVELS + 2)
sidebandStarts[0] = 0
sidebandStarts[1] = firstBandSize
for (i in 2..DWT_LEVELS + 1) {
sidebandStarts[i] = sidebandStarts[i - 1] + (firstBandSize shl (i - 2))
}
for (i in 0 until count) {
var sideband = DWT_LEVELS
for (s in 0..DWT_LEVELS) {
if (i < sidebandStarts[s + 1]) { sideband = s; break }
}
val normalisedVal = lambdaDecompand(quantised[i], maxIndex)
val weight = BASE_QUANTISER_WEIGHTS[channel][sideband]
coeffs[i] = normalisedVal * COEFF_SCALARS[sideband] * weight
}
}
private fun msToLR(mid: FloatArray, side: FloatArray, left: FloatArray, right: FloatArray, count: Int) {
for (i in 0 until count) {
left[i] = (mid[i] + side[i]).coerceIn(-1.0f, 1.0f)
right[i] = (mid[i] - side[i]).coerceIn(-1.0f, 1.0f)
}
}
private fun gammaExpand(left: FloatArray, right: FloatArray, count: Int) {
for (i in 0 until count) {
val x = left[i]; val a = kotlin.math.abs(x)
left[i] = if (x >= 0) a * a else -(a * a)
val y = right[i]; val b = kotlin.math.abs(y)
right[i] = if (y >= 0) b * b else -(b * b)
}
}
// De-emphasis: y[n] = x[n] + 0.5 * y[n-1] (state persists across chunks)
private fun deemphasis(left: FloatArray, right: FloatArray, count: Int, state: TadDecoderState) {
for (i in 0 until count) {
val yL = left[i] + 0.5f * state.prevYL
state.prevYL = yL; left[i] = yL
val yR = right[i] + 0.5f * state.prevYR
state.prevYR = yR; right[i] = yR
}
}
}

View File

@@ -0,0 +1,456 @@
package net.torvald.terrarum.tav
import com.badlogic.gdx.graphics.Pixmap
import io.airlift.compress.zstd.ZstdInputStream
import java.io.ByteArrayInputStream
import java.io.InputStream
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
/**
* TAV file demuxer and frame/audio coordinator.
* Owns the InputStream, demuxes packets, and decodes video+audio on a background thread.
* Provides lock-free SPSC ring buffers for thread-safe GL and audio mixer consumption.
*/
class TavDecoder(
private val stream: InputStream,
val looping: Boolean = false
) {
companion object {
private const val FRAME_RING_SIZE = 32
private const val AUDIO_RING_SIZE = 65536
private const val BACK_PRESSURE_SLEEP_MS = 2L
private val TAV_MAGIC = byteArrayOf(0x1F, 'T'.code.toByte(), 'S'.code.toByte(), 'V'.code.toByte(),
'M'.code.toByte(), 'T'.code.toByte(), 'A'.code.toByte(), 'V'.code.toByte())
}
// -------------------------------------------------------------------------
// Header
// -------------------------------------------------------------------------
lateinit var header: TavVideoDecode.TavHeader
private set
val videoWidth: Int get() = header.width
val videoHeight: Int get() = header.height
val fps: Int get() = header.fps
val totalFrames: Long get() = header.totalFrames
val hasAudio: Boolean get() = header.hasAudio
val isPerceptual: Boolean get() = header.isPerceptual
val isMonoblock: Boolean get() = header.isMonoblock
// -------------------------------------------------------------------------
// Ring buffers
// -------------------------------------------------------------------------
// Video: pre-allocated Pixmap ring buffer
private lateinit var frameRing: Array<Pixmap>
val frameReadIdx = AtomicInteger(0)
val frameWriteIdx = AtomicInteger(0)
// Audio: circular Float32 ring
private lateinit var audioRingL: FloatArray
private lateinit var audioRingR: FloatArray
val audioReadPos = AtomicLong(0L)
val audioWritePos = AtomicLong(0L)
// -------------------------------------------------------------------------
// Thread state
// -------------------------------------------------------------------------
private var decodeThread: Thread? = null
val shouldStop = AtomicBoolean(false)
val isFinished = AtomicBoolean(false)
// -------------------------------------------------------------------------
// Codec state
// -------------------------------------------------------------------------
private var prevCoeffsY: FloatArray? = null
private var prevCoeffsCo: FloatArray? = null
private var prevCoeffsCg: FloatArray? = null
private val tadState = TadDecode.TadDecoderState()
private var frameCounter = 0
private var gopFrameCounter = 0 // for grain synthesis RNG continuity
// -------------------------------------------------------------------------
// Looping support: remember stream position after header
// -------------------------------------------------------------------------
private var streamBuffer: ByteArray? = null // null when not a resettable stream
private var headerSize = 0
// -------------------------------------------------------------------------
// Lifecycle
// -------------------------------------------------------------------------
init {
// Buffer the stream to support reset for looping
val rawBytes = stream.readBytes()
streamBuffer = rawBytes
headerSize = parseHeader(rawBytes)
}
private fun parseHeader(bytes: ByteArray): Int {
// Verify magic
for (i in 0..7) {
if (bytes[i] != TAV_MAGIC[i]) throw IllegalArgumentException("Not a TAV file (magic mismatch)")
}
var ptr = 8
val version = bytes[ptr++].toInt() and 0xFF
val width = ((bytes[ptr].toInt() and 0xFF) or ((bytes[ptr+1].toInt() and 0xFF) shl 8)).also { ptr += 2 }
val height = ((bytes[ptr].toInt() and 0xFF) or ((bytes[ptr+1].toInt() and 0xFF) shl 8)).also { ptr += 2 }
val fps = bytes[ptr++].toInt() and 0xFF
val totalFrames = (
(bytes[ptr ].toLong() and 0xFF) or
((bytes[ptr+1].toLong() and 0xFF) shl 8) or
((bytes[ptr+2].toLong() and 0xFF) shl 16) or
((bytes[ptr+3].toLong() and 0xFF) shl 24)
).also { ptr += 4 }
val waveletFilter = bytes[ptr++].toInt() and 0xFF
val decompLevels = bytes[ptr++].toInt() and 0xFF
val qIndexY = bytes[ptr++].toInt() and 0xFF
val qIndexCo = bytes[ptr++].toInt() and 0xFF
val qIndexCg = bytes[ptr++].toInt() and 0xFF
val extraFlags = bytes[ptr++].toInt() and 0xFF
val videoFlags = bytes[ptr++].toInt() and 0xFF
val encoderQuality = bytes[ptr++].toInt() and 0xFF
val channelLayout = bytes[ptr++].toInt() and 0xFF
val entropyCoder = bytes[ptr++].toInt() and 0xFF
val encoderPreset = bytes[ptr++].toInt() and 0xFF
ptr += 2 // reserved + device orientation (ignored) + file role
header = TavVideoDecode.TavHeader(
version = version, width = width, height = height,
fps = fps, totalFrames = totalFrames,
waveletFilter = waveletFilter, decompLevels = decompLevels,
qIndexY = qIndexY, qIndexCo = qIndexCo, qIndexCg = qIndexCg,
extraFlags = extraFlags, videoFlags = videoFlags,
encoderQuality = encoderQuality, channelLayout = channelLayout,
entropyCoder = entropyCoder, encoderPreset = encoderPreset
)
return ptr // byte offset to first packet
}
private fun allocateBuffers() {
frameRing = Array(FRAME_RING_SIZE) {
Pixmap(videoWidth, videoHeight, Pixmap.Format.RGBA8888)
}
audioRingL = FloatArray(AUDIO_RING_SIZE)
audioRingR = FloatArray(AUDIO_RING_SIZE)
}
fun start() {
allocateBuffers()
shouldStop.set(false)
isFinished.set(false)
decodeThread = Thread(::decodeLoop, "tav-decode").also {
it.isDaemon = true
it.start()
}
}
fun stop() {
shouldStop.set(true)
decodeThread?.join(2000)
decodeThread = null
}
fun dispose() {
stop()
if (::frameRing.isInitialized) {
for (px in frameRing) px.dispose()
}
}
// -------------------------------------------------------------------------
// Decode loop (background thread)
// -------------------------------------------------------------------------
private fun decodeLoop() {
val bytes = streamBuffer ?: return
var ptr = headerSize
try {
while (!shouldStop.get()) {
if (ptr >= bytes.size) {
if (looping) {
ptr = headerSize
prevCoeffsY = null; prevCoeffsCo = null; prevCoeffsCg = null
tadState.prevYL = 0f; tadState.prevYR = 0f
continue
} else {
isFinished.set(true)
break
}
}
val packetType = bytes[ptr++].toInt() and 0xFF
when (packetType) {
// --- Special fixed-size packets (no payload size) ---
0xFF -> { /* sync, no-op */ }
0xFE -> { /* NTSC sync, no-op */ }
0x00 -> { /* no-op */ }
0xF0 -> { /* loop point start, no-op */ }
0xF1 -> { /* loop point end, no-op */ }
0xFC -> {
// GOP sync: 1 extra byte (frame count)
if (ptr < bytes.size) ptr++ // skip frame count byte
}
0xFD -> {
// Timecode: 8-byte uint64 nanosecond timestamp
ptr += 8
}
// --- Video packets ---
0x10, 0x11 -> {
val payloadSize = readInt32LE(bytes, ptr); ptr += 4
if (ptr + payloadSize > bytes.size) { isFinished.set(true); break }
val payload = bytes.copyOfRange(ptr, ptr + payloadSize); ptr += payloadSize
val blockData = if (!header.noZstd)
ZstdInputStream(ByteArrayInputStream(payload)).use { it.readBytes() }
else payload
// Back-pressure: wait until there's space in the ring
while (frameRingFull() && !shouldStop.get()) Thread.sleep(BACK_PRESSURE_SLEEP_MS)
if (shouldStop.get()) break
val result = TavVideoDecode.decodeFrame(
blockData, header,
prevCoeffsY, prevCoeffsCo, prevCoeffsCg,
frameCounter
)
prevCoeffsY = result.coeffsY
prevCoeffsCo = result.coeffsCo
prevCoeffsCg = result.coeffsCg
val rgba = result.rgba
if (rgba != null) {
writeFrameToRing(rgba)
} else {
// SKIP frame: duplicate the previous ring entry
duplicateLastFrame()
}
frameCounter++
}
0x12 -> {
// GOP Unified
val gopSize = bytes[ptr++].toInt() and 0xFF
val payloadSize = readInt32LE(bytes, ptr); ptr += 4
if (ptr + payloadSize > bytes.size) { isFinished.set(true); break }
val payload = bytes.copyOfRange(ptr, ptr + payloadSize); ptr += payloadSize
val frames = TavVideoDecode.decodeGop(payload, header, gopSize, frameCounter = gopFrameCounter)
gopFrameCounter += gopSize
for (rgba in frames) {
while (frameRingFull() && !shouldStop.get()) Thread.sleep(BACK_PRESSURE_SLEEP_MS)
if (shouldStop.get()) break
writeFrameToRing(rgba)
frameCounter++
}
}
// --- Audio packets ---
0x21 -> {
val payloadSize = readInt32LE(bytes, ptr); ptr += 4
val payload = bytes.copyOfRange(ptr, ptr + payloadSize); ptr += payloadSize
val pcm = TadDecode.decodePcm8(payload)
writeAudioToRing(pcm.first, pcm.second, pcm.first.size)
}
0x22 -> {
val payloadSize = readInt32LE(bytes, ptr); ptr += 4
val payload = bytes.copyOfRange(ptr, ptr + payloadSize); ptr += payloadSize
val pcm = TadDecode.decodePcm16(payload)
writeAudioToRing(pcm.first, pcm.second, pcm.first.size)
}
0x24 -> {
// TAD packet structure:
// uint16 sampleCount, uint32 outerSize (=compressedSize+7)
// TAD chunk header: uint16 sampleCount, uint8 maxIndex, uint32 compressedSize
// * Zstd payload
val sampleCount = readInt16LE(bytes, ptr); ptr += 2
val outerSize = readInt32LE(bytes, ptr); ptr += 4 // = compSize + 7
val chunkSamples = readInt16LE(bytes, ptr); ptr += 2
val maxIndex = bytes[ptr++].toInt() and 0xFF
val compSize = readInt32LE(bytes, ptr); ptr += 4
if (ptr + compSize > bytes.size) { isFinished.set(true); break }
val payload = bytes.copyOfRange(ptr, ptr + compSize); ptr += compSize
while (audioRingFull(chunkSamples) && !shouldStop.get()) Thread.sleep(BACK_PRESSURE_SLEEP_MS)
if (shouldStop.get()) break
try {
val pcm = TadDecode.decodeTadChunk(payload, chunkSamples, maxIndex, tadState)
writeAudioToRing(pcm.first, pcm.second, chunkSamples)
} catch (e: Exception) {
// Silently drop corrupted audio packets
}
}
// --- Extended header and metadata: read and skip ---
0xEF -> {
// TAV extended header: uint16 num_kvp, then key-value pairs
if (ptr + 2 > bytes.size) { isFinished.set(true); break }
val numKvp = readInt16LE(bytes, ptr); ptr += 2
repeat(numKvp) {
if (ptr + 5 <= bytes.size) {
ptr += 4 // key[4]
val valueType = bytes[ptr++].toInt() and 0xFF
val valueSize = when (valueType) {
0x00 -> 2; 0x01 -> 3; 0x02 -> 4; 0x03 -> 6; 0x04 -> 8
0x10 -> { val len = readInt16LE(bytes, ptr); ptr += 2; len }
else -> 0
}
ptr += valueSize
}
}
}
in 0xE0..0xEE -> {
// Standard metadata: uint32 size, * payload
val payloadSize = readInt32LE(bytes, ptr); ptr += 4
ptr += payloadSize
}
else -> {
// Unknown packet with payload: uint32 size + payload
if (ptr + 4 <= bytes.size) {
val payloadSize = readInt32LE(bytes, ptr); ptr += 4
ptr += payloadSize
} else {
isFinished.set(true); break
}
}
}
}
} catch (e: InterruptedException) {
// Thread interrupted, exit cleanly
} catch (e: Exception) {
System.err.println("[TavDecoder] Decode error: ${e.message}")
}
if (!shouldStop.get()) isFinished.set(true)
}
// -------------------------------------------------------------------------
// Frame ring operations
// -------------------------------------------------------------------------
private fun frameRingFull(): Boolean {
val write = frameWriteIdx.get()
val read = frameReadIdx.get()
return ((write + 1) % FRAME_RING_SIZE) == (read % FRAME_RING_SIZE)
}
private fun writeFrameToRing(rgba: ByteArray) {
val idx = frameWriteIdx.get() % FRAME_RING_SIZE
val px = frameRing[idx]
val buf = px.pixels
buf.position(0)
buf.put(rgba)
buf.position(0)
frameWriteIdx.incrementAndGet()
}
private fun duplicateLastFrame() {
val writeIdx = frameWriteIdx.get()
if (writeIdx == frameReadIdx.get()) return // ring empty, nothing to duplicate
val srcIdx = ((writeIdx - 1 + FRAME_RING_SIZE) % FRAME_RING_SIZE)
val dstIdx = writeIdx % FRAME_RING_SIZE
val src = frameRing[srcIdx]
val dst = frameRing[dstIdx]
src.pixels.position(0)
dst.pixels.position(0)
dst.pixels.put(src.pixels)
src.pixels.position(0)
dst.pixels.position(0)
frameWriteIdx.incrementAndGet()
}
/** Returns the current decoded Pixmap without advancing, or null if no frame available. */
fun getFramePixmap(): Pixmap? {
val read = frameReadIdx.get()
val write = frameWriteIdx.get()
if (read == write) return null
return frameRing[read % FRAME_RING_SIZE]
}
/** Advance to the next decoded frame. */
fun advanceFrame() {
val read = frameReadIdx.get()
val write = frameWriteIdx.get()
if (read != write) frameReadIdx.incrementAndGet()
}
// -------------------------------------------------------------------------
// Audio ring operations
// -------------------------------------------------------------------------
private fun audioRingFull(needed: Int): Boolean {
val avail = AUDIO_RING_SIZE - (audioWritePos.get() - audioReadPos.get()).toInt()
return avail < needed
}
private fun writeAudioToRing(left: FloatArray, right: FloatArray, count: Int) {
var writePos = audioWritePos.get()
for (i in 0 until count) {
val slot = (writePos % AUDIO_RING_SIZE).toInt()
audioRingL[slot] = left[i]
audioRingR[slot] = right[i]
writePos++
}
audioWritePos.set(writePos)
}
/**
* Read audio samples from the ring buffer into the caller's buffers.
* @return number of samples actually read
*/
fun readAudioSamples(bufL: FloatArray, bufR: FloatArray): Int {
val available = (audioWritePos.get() - audioReadPos.get()).toInt().coerceAtMost(bufL.size)
if (available <= 0) return 0
var readPos = audioReadPos.get()
for (i in 0 until available) {
val slot = (readPos % AUDIO_RING_SIZE).toInt()
bufL[i] = audioRingL[slot]
bufR[i] = audioRingR[slot]
readPos++
}
audioReadPos.set(readPos)
return available
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private fun readInt32LE(data: ByteArray, offset: Int): Int {
val b0 = data[offset ].toInt() and 0xFF
val b1 = data[offset+1].toInt() and 0xFF
val b2 = data[offset+2].toInt() and 0xFF
val b3 = data[offset+3].toInt() and 0xFF
return b0 or (b1 shl 8) or (b2 shl 16) or (b3 shl 24)
}
private fun readInt16LE(data: ByteArray, offset: Int): Int {
val b0 = data[offset ].toInt() and 0xFF
val b1 = data[offset+1].toInt() and 0xFF
return b0 or (b1 shl 8)
}
}

View File

@@ -0,0 +1,685 @@
package net.torvald.terrarum.tav
import io.airlift.compress.zstd.ZstdInputStream
import java.io.ByteArrayInputStream
import kotlin.math.roundToInt
/**
* TAV video frame decoder (stateless pipeline functions).
* Handles I-frames (0x10), P-frames (0x11) and GOP Unified (0x12) packets.
* Supports YCoCg-R colour space only (odd version numbers).
*
* Ported from GraphicsJSR223Delegate.kt in the TSVM project.
*/
object TavVideoDecode {
// Exponential quantiser lookup table (index → value)
private val QLUT = intArrayOf(
1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,
31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,
61,62,63,64,66,68,70,72,74,76,78,80,82,84,86,88,90,92,94,96,98,100,102,104,106,108,110,112,
114,116,118,120,122,124,126,128,132,136,140,144,148,152,156,160,164,168,172,176,180,184,188,
192,196,200,204,208,212,216,220,224,228,232,236,240,244,248,252,256,264,272,280,288,296,304,
312,320,328,336,344,352,360,368,376,384,392,400,408,416,424,432,440,448,456,464,472,480,488,
496,504,512,528,544,560,576,592,608,624,640,656,672,688,704,720,736,752,768,784,800,816,832,
848,864,880,896,912,928,944,960,976,992,1008,1024,1056,1088,1120,1152,1184,1216,1248,1280,
1312,1344,1376,1408,1440,1472,1504,1536,1568,1600,1632,1664,1696,1728,1760,1792,1824,1856,
1888,1920,1952,1984,2016,2048,2112,2176,2240,2304,2368,2432,2496,2560,2624,2688,2752,2816,
2880,2944,3008,3072,3136,3200,3264,3328,3392,3456,3520,3584,3648,3712,3776,3840,3904,3968,
4032,4096
)
private val ANISOTROPY_MULT = floatArrayOf(5.1f, 3.8f, 2.7f, 2.0f, 1.5f, 1.2f, 1.0f)
private val ANISOTROPY_BIAS = floatArrayOf(0.4f, 0.3f, 0.2f, 0.1f, 0.0f, 0.0f, 0.0f)
private val ANISOTROPY_MULT_CHROMA = floatArrayOf(7.0f, 6.0f, 5.0f, 4.0f, 3.0f, 2.0f, 1.0f)
private val ANISOTROPY_BIAS_CHROMA = floatArrayOf(1.0f, 0.8f, 0.6f, 0.4f, 0.2f, 0.0f, 0.0f)
// -------------------------------------------------------------------------
// Frame data class
// -------------------------------------------------------------------------
/** Decoded frame info returned to the caller. */
data class TavHeader(
val version: Int,
val width: Int,
val height: Int,
val fps: Int,
val totalFrames: Long,
val waveletFilter: Int,
val decompLevels: Int,
val qIndexY: Int,
val qIndexCo: Int,
val qIndexCg: Int,
val extraFlags: Int,
val videoFlags: Int,
val encoderQuality: Int,
val channelLayout: Int,
val entropyCoder: Int,
val encoderPreset: Int
) {
val hasAudio: Boolean get() = (extraFlags and 0x01) != 0
val isLooping: Boolean get() = (extraFlags and 0x04) != 0
val isInterlaced:Boolean get() = (videoFlags and 0x01) != 0
val isLossless: Boolean get() = (videoFlags and 0x04) != 0
val noZstd: Boolean get() = (videoFlags and 0x10) != 0
val hasNoVideo: Boolean get() = (videoFlags and 0x80) != 0
/** Monoblock mode: version 3-6 */
val isMonoblock: Boolean get() = version in 3..6
val qY: Int get() = QLUT.getOrElse(qIndexY - 1) { 1 }
val qCo: Int get() = QLUT.getOrElse(qIndexCo - 1) { 1 }
val qCg: Int get() = QLUT.getOrElse(qIndexCg - 1) { 1 }
val isPerceptual: Boolean get() = (version % 8) in 5..8
/** Temporal motion coder: 0=Haar (version<=8), 1=CDF5/3 (version>8) */
val temporalMotionCoder: Int get() = if (version > 8) 1 else 0
}
// -------------------------------------------------------------------------
// Subband layout
// -------------------------------------------------------------------------
data class SubbandInfo(val level: Int, val subbandType: Int, val coeffStart: Int, val coeffCount: Int)
fun calculateSubbandLayout(width: Int, height: Int, decompLevels: Int): List<SubbandInfo> {
val subbands = mutableListOf<SubbandInfo>()
val llWidth = width shr decompLevels
val llHeight = height shr decompLevels
subbands.add(SubbandInfo(decompLevels, 0, 0, llWidth * llHeight))
var offset = llWidth * llHeight
for (level in decompLevels downTo 1) {
val lw = width shr (decompLevels - level + 1)
val lh = height shr (decompLevels - level + 1)
val sz = lw * lh
subbands.add(SubbandInfo(level, 1, offset, sz)); offset += sz
subbands.add(SubbandInfo(level, 2, offset, sz)); offset += sz
subbands.add(SubbandInfo(level, 3, offset, sz)); offset += sz
}
return subbands
}
// -------------------------------------------------------------------------
// Perceptual weight calculation
// -------------------------------------------------------------------------
fun getPerceptualWeight(qIndex: Int, qYGlobal: Int, level0: Int, subbandType: Int, isChroma: Boolean, maxLevels: Int): Float {
val level = 1.0f + ((level0 - 1.0f) / (maxLevels - 1.0f)) * 5.0f
val qualityLevel = deriveEncoderQIndex(qIndex, qYGlobal)
if (!isChroma) {
if (subbandType == 0) return perceptualLL(level)
val lh = perceptualLH(level)
if (subbandType == 1) return lh
val hl = perceptualHL(qualityLevel, lh)
val fineDetail = if (level in 1.8f..2.2f) 0.92f else if (level in 2.8f..3.2f) 0.88f else 1.0f
if (subbandType == 2) return hl * fineDetail
return perceptualHH(lh, hl, level) * fineDetail
} else {
val base = perceptualChromaBasecurve(qualityLevel, level - 1)
return when (subbandType) {
0 -> 1.0f
1 -> base.coerceAtLeast(1.0f)
2 -> (base * ANISOTROPY_MULT_CHROMA[qualityLevel]).coerceAtLeast(1.0f)
else -> (base * ANISOTROPY_MULT_CHROMA[qualityLevel] + ANISOTROPY_BIAS_CHROMA[qualityLevel]).coerceAtLeast(1.0f)
}
}
}
private fun deriveEncoderQIndex(qIndex: Int, qYGlobal: Int): Int {
if (qIndex > 0) return qIndex - 1
return when {
qYGlobal >= 79 -> 0
qYGlobal >= 47 -> 1
qYGlobal >= 23 -> 2
qYGlobal >= 11 -> 3
qYGlobal >= 5 -> 4
qYGlobal >= 2 -> 5
else -> 6
}
}
private fun perceptualLH(level: Float): Float {
val H4 = 1.2f; val K = 2f; val K12 = K * 12f; val x = level
val Lx = H4 - ((K + 1f) / 15f) * (x - 4f)
val C3 = -1f / 45f * (K12 + 92)
val G3x = (-x / 180f) * (K12 + 5 * x * x - 60 * x + 252) - C3 + H4
return if (level >= 4f) Lx else G3x
}
private fun perceptualHL(quality: Int, lh: Float): Float =
lh * ANISOTROPY_MULT[quality] + ANISOTROPY_BIAS[quality]
private fun perceptualHH(lh: Float, hl: Float, level: Float): Float {
val kx = (kotlin.math.sqrt(level.toDouble()).toFloat() - 1f) * 0.5f + 0.5f
return lh * (1f - kx) + hl * kx
}
private fun perceptualLL(level: Float): Float {
val n = perceptualLH(level)
val m = perceptualLH(level - 1) / n
return n / m
}
private fun perceptualChromaBasecurve(qualityLevel: Int, level: Float): Float =
1.0f - (1.0f / (0.5f * qualityLevel * qualityLevel + 1.0f)) * (level - 4f)
// -------------------------------------------------------------------------
// Dequantisation
// -------------------------------------------------------------------------
fun dequantisePerceptual(
quantised: ShortArray, dequantised: FloatArray,
subbands: List<SubbandInfo>,
baseQuantiser: Float, isChroma: Boolean,
qIndex: Int, qYGlobal: Int, decompLevels: Int
) {
val weights = FloatArray(quantised.size) { 1.0f }
for (sb in subbands) {
val w = getPerceptualWeight(qIndex, qYGlobal, sb.level, sb.subbandType, isChroma, decompLevels)
for (i in 0 until sb.coeffCount) {
val idx = sb.coeffStart + i
if (idx < weights.size) weights[idx] = w
}
}
for (i in quantised.indices) {
if (i < dequantised.size) dequantised[i] = quantised[i] * baseQuantiser * weights[i]
}
}
fun dequantiseUniform(quantised: ShortArray, dequantised: FloatArray, baseQuantiser: Float) {
for (i in quantised.indices) {
if (i < dequantised.size) dequantised[i] = quantised[i] * baseQuantiser
}
}
// -------------------------------------------------------------------------
// Grain synthesis
// -------------------------------------------------------------------------
fun grainSynthesis(coeffs: FloatArray, width: Int, height: Int,
frameNum: Int, subbands: List<SubbandInfo>, qYGlobal: Int, encoderPreset: Int) {
if ((encoderPreset and 0x02) != 0) return // Anime preset: disable grain
val noiseAmplitude = qYGlobal.coerceAtMost(32) * 0.8f
for (sb in subbands) {
if (sb.level == 0) continue // Skip LL band
for (i in 0 until sb.coeffCount) {
val idx = sb.coeffStart + i
if (idx >= coeffs.size) continue
val y = idx / width
val x = idx % width
val rngVal = grainRng(frameNum.toUInt(), (sb.level + sb.subbandType * 31 + 16777619).toUInt(), x.toUInt(), y.toUInt())
val noise = grainTriangularNoise(rngVal)
coeffs[idx] -= noise * noiseAmplitude
}
}
}
private fun grainRng(frame: UInt, band: UInt, x: UInt, y: UInt): UInt {
val key = frame * 0x9e3779b9u xor band * 0x7f4a7c15u xor (y shl 16) xor x
var hash = key
hash = hash xor (hash shr 16)
hash = hash * 0x7feb352du
hash = hash xor (hash shr 15)
hash = hash * 0x846ca68bu
hash = hash xor (hash shr 16)
return hash
}
private fun grainTriangularNoise(rngVal: UInt): Float {
val u1 = (rngVal and 0xFFFFu).toFloat() / 65535.0f
val u2 = ((rngVal shr 16) and 0xFFFFu).toFloat() / 65535.0f
return (u1 + u2) - 1.0f
}
// -------------------------------------------------------------------------
// YCoCg-R to RGB
// -------------------------------------------------------------------------
/**
* Convert YCoCg-R float arrays to RGBA8888 byte array.
* Each pixel = 4 bytes (R, G, B, A=255).
*/
fun ycocgrToRgba(y: FloatArray, co: FloatArray, cg: FloatArray,
width: Int, height: Int,
channelLayout: Int = 0): ByteArray {
val out = ByteArray(width * height * 4)
for (i in 0 until width * height) {
val yv = y[i]; val cov = co[i]; val cgv = cg[i]
val tmp = yv - cgv / 2.0f
val g = cgv + tmp
val b = tmp - cov / 2.0f
val r = cov + b
out[i * 4 ] = r.roundToInt().coerceIn(0, 255).toByte()
out[i * 4 + 1] = g.roundToInt().coerceIn(0, 255).toByte()
out[i * 4 + 2] = b.roundToInt().coerceIn(0, 255).toByte()
out[i * 4 + 3] = 0xFF.toByte()
}
return out
}
// -------------------------------------------------------------------------
// Temporal quantiser scale helpers (for GOP decode)
// -------------------------------------------------------------------------
private fun getTemporalSubbandLevel(frameIdx: Int, numFrames: Int, temporalLevels: Int): Int {
val framesPerLevel0 = numFrames shr temporalLevels
return when {
frameIdx < framesPerLevel0 -> 0
frameIdx < (numFrames shr 1) -> 1
else -> 2
}
}
private fun getTemporalQuantiserScale(encoderPreset: Int, temporalLevel: Int): Float {
val beta = if (encoderPreset and 0x01 == 1) 0.0f else 0.6f
val kappa = if (encoderPreset and 0x01 == 1) 1.0f else 1.14f
return Math.pow(2.0, (beta * Math.pow(temporalLevel.toDouble(), kappa.toDouble()))).toFloat()
}
// -------------------------------------------------------------------------
// Coefficients from block data (significance-map or EZBC)
// -------------------------------------------------------------------------
private fun readInt32LE(data: ByteArray, offset: Int): Int {
val b0 = data[offset ].toInt() and 0xFF
val b1 = data[offset+1].toInt() and 0xFF
val b2 = data[offset+2].toInt() and 0xFF
val b3 = data[offset+3].toInt() and 0xFF
return b0 or (b1 shl 8) or (b2 shl 16) or (b3 shl 24)
}
/**
* Decode quantised coefficients from block data (single frame).
* Supports EZBC (entropyCoder=1) and 2-bit significance map (entropyCoder=0).
*/
private fun extractCoefficients(
blockData: ByteArray, offset: Int,
coeffCount: Int, channelLayout: Int, entropyCoder: Int,
qY: ShortArray, qCo: ShortArray, qCg: ShortArray
) {
if (entropyCoder == 1) {
EzbcDecode.decode2D(blockData, offset, channelLayout, qY, qCo, qCg, null)
} else {
extractCoeffsSigMap(blockData, offset, coeffCount, channelLayout, qY, qCo, qCg)
}
}
/** 2-bit significance map decoder (legacy format). */
private fun extractCoeffsSigMap(
data: ByteArray, offset: Int, coeffCount: Int, channelLayout: Int,
outY: ShortArray, outCo: ShortArray, outCg: ShortArray
) {
val hasY = (channelLayout and 4) == 0
val hasCoCg = (channelLayout and 2) == 0
val mapBytes = (coeffCount * 2 + 7) / 8
val yMapStart = if (hasY) { offset } else -1
val coMapStart = if (hasCoCg) { offset + (if (hasY) mapBytes else 0) } else -1
val cgMapStart = if (hasCoCg) { coMapStart + mapBytes } else -1
var yOthers = 0; var coOthers = 0; var cgOthers = 0
fun countOthers(mapStart: Int): Int {
var cnt = 0
for (i in 0 until coeffCount) {
val bitPos = i * 2
val byteIdx = bitPos / 8; val bitOffset = bitPos % 8
val byteVal = data[mapStart + byteIdx].toInt() and 0xFF
var code = (byteVal shr bitOffset) and 0x03
if (bitOffset == 7 && byteIdx + 1 < mapBytes) {
val nb = data[mapStart + byteIdx + 1].toInt() and 0xFF
code = (code and 0x01) or ((nb and 0x01) shl 1)
}
if (code == 3) cnt++
}
return cnt
}
if (hasY) yOthers = countOthers(yMapStart)
if (hasCoCg) { coOthers = countOthers(coMapStart); cgOthers = countOthers(cgMapStart) }
val numChannels = if (hasY && hasCoCg) 3 else if (hasY) 1 else 2
var valueOffset = offset + mapBytes * numChannels
val yValStart = if (hasY) { val s = valueOffset; valueOffset += yOthers * 2; s } else -1
val coValStart = if (hasCoCg) { val s = valueOffset; valueOffset += coOthers * 2; s } else -1
val cgValStart = if (hasCoCg) { val s = valueOffset; valueOffset += cgOthers * 2; s } else -1
fun decodeChannel(mapStart: Int, valStart: Int, out: ShortArray) {
var vIdx = 0
for (i in 0 until coeffCount) {
val bitPos = i * 2; val byteIdx = bitPos / 8; val bitOffset = bitPos % 8
val byteVal = data[mapStart + byteIdx].toInt() and 0xFF
var code = (byteVal shr bitOffset) and 0x03
if (bitOffset == 7 && byteIdx + 1 < mapBytes) {
code = (code and 0x01) or ((data[mapStart + byteIdx + 1].toInt() and 0x01) shl 1)
}
out[i] = when (code) {
0 -> 0
1 -> 1
2 -> (-1).toShort()
3 -> {
val vp = valStart + vIdx * 2; vIdx++
val lo = data[vp ].toInt() and 0xFF
val hi = data[vp+1].toInt()
((hi shl 8) or lo).toShort()
}
else -> 0
}
}
}
if (hasY) decodeChannel(yMapStart, yValStart, outY)
if (hasCoCg) { decodeChannel(coMapStart, coValStart, outCo); decodeChannel(cgMapStart, cgValStart, outCg) }
}
// -------------------------------------------------------------------------
// I-frame / P-frame decode (monoblock only)
// -------------------------------------------------------------------------
/**
* Decode an I-frame or P-frame packet payload (already Zstd-decompressed).
* Returns RGBA8888 pixels and the new float coefficients for P-frame reference.
*
* @param blockData decompressed block data (after ZstdInputStream)
* @param header parsed TAV header
* @param prevCoeffsY/Co/Cg previous frame coefficients for P-frame delta (null for I-frame)
* @param frameNum frame counter for grain synthesis RNG
* @return Triple(rgbaPixels, newCoeffsY, newCo, newCg) — newCoeffs are null for GOP frames
*/
fun decodeFrame(
blockData: ByteArray,
header: TavHeader,
prevCoeffsY: FloatArray?,
prevCoeffsCo: FloatArray?,
prevCoeffsCg: FloatArray?,
frameNum: Int
): FrameDecodeResult {
val width = header.width
val height = header.height
val coeffCount = width * height
var ptr = 0
// Read tile header (4 bytes)
val modeRaw = blockData[ptr++].toInt() and 0xFF
val qYOverride = blockData[ptr++].toInt() and 0xFF
val qCoOverride = blockData[ptr++].toInt() and 0xFF
val qCgOverride = blockData[ptr++].toInt() and 0xFF
val baseMode = modeRaw and 0x0F
val haarNibble = modeRaw shr 4
val haarLevel = if (baseMode == 0x02 && haarNibble > 0) haarNibble + 1 else 0
val qY = if (qYOverride != 0) QLUT[qYOverride - 1] else header.qY
val qCo = if (qCoOverride != 0) QLUT[qCoOverride - 1] else header.qCo
val qCg = if (qCgOverride != 0) QLUT[qCgOverride - 1] else header.qCg
val quantY = ShortArray(coeffCount)
val quantCo = ShortArray(coeffCount)
val quantCg = ShortArray(coeffCount)
val floatY = FloatArray(coeffCount)
val floatCo = FloatArray(coeffCount)
val floatCg = FloatArray(coeffCount)
val subbands = calculateSubbandLayout(width, height, header.decompLevels)
when (baseMode) {
0x00 -> { // SKIP - caller should copy previous frame
return FrameDecodeResult(null, prevCoeffsY, prevCoeffsCo, prevCoeffsCg, frameMode = 'S')
}
0x01 -> { // INTRA
extractCoefficients(blockData, ptr, coeffCount, header.channelLayout, header.entropyCoder,
quantY, quantCo, quantCg)
if (header.isPerceptual) {
dequantisePerceptual(quantY, floatY, subbands, qY.toFloat(), false, header.encoderQuality, header.qY, header.decompLevels)
dequantisePerceptual(quantCo, floatCo, subbands, qCo.toFloat(), true, header.encoderQuality, header.qY, header.decompLevels)
dequantisePerceptual(quantCg, floatCg, subbands, qCg.toFloat(), true, header.encoderQuality, header.qY, header.decompLevels)
} else {
dequantiseUniform(quantY, floatY, qY.toFloat())
dequantiseUniform(quantCo, floatCo, qCo.toFloat())
dequantiseUniform(quantCg, floatCg, qCg.toFloat())
}
grainSynthesis(floatY, width, height, frameNum, subbands, header.qY, header.encoderPreset)
DwtUtil.inverseMultilevel2D(floatY, width, height, header.decompLevels, header.waveletFilter)
DwtUtil.inverseMultilevel2D(floatCo, width, height, header.decompLevels, header.waveletFilter)
DwtUtil.inverseMultilevel2D(floatCg, width, height, header.decompLevels, header.waveletFilter)
}
0x02 -> { // DELTA
extractCoefficients(blockData, ptr, coeffCount, header.channelLayout, header.entropyCoder,
quantY, quantCo, quantCg)
val deltaY = FloatArray(coeffCount) { quantY[it].toFloat() * qY }
val deltaCo = FloatArray(coeffCount) { quantCo[it].toFloat() * qCo }
val deltaCg = FloatArray(coeffCount) { quantCg[it].toFloat() * qCg }
if (haarLevel > 0) {
DwtUtil.inverseMultilevel2D(deltaY, width, height, haarLevel, 255)
DwtUtil.inverseMultilevel2D(deltaCo, width, height, haarLevel, 255)
DwtUtil.inverseMultilevel2D(deltaCg, width, height, haarLevel, 255)
}
val pY = prevCoeffsY ?: FloatArray(coeffCount)
val pCo = prevCoeffsCo ?: FloatArray(coeffCount)
val pCg = prevCoeffsCg ?: FloatArray(coeffCount)
for (i in 0 until coeffCount) {
floatY[i] = pY[i] + deltaY[i]
floatCo[i] = pCo[i] + deltaCo[i]
floatCg[i] = pCg[i] + deltaCg[i]
}
grainSynthesis(floatY, width, height, frameNum, subbands, header.qY, header.encoderPreset)
DwtUtil.inverseMultilevel2D(floatY, width, height, header.decompLevels, header.waveletFilter)
DwtUtil.inverseMultilevel2D(floatCo, width, height, header.decompLevels, header.waveletFilter)
DwtUtil.inverseMultilevel2D(floatCg, width, height, header.decompLevels, header.waveletFilter)
}
}
val rgba = ycocgrToRgba(floatY, floatCo, floatCg, width, height, header.channelLayout)
return FrameDecodeResult(rgba, floatY.clone(), floatCo.clone(), floatCg.clone())
}
data class FrameDecodeResult(
val rgba: ByteArray?, // null on SKIP frames
val coeffsY: FloatArray?,
val coeffsCo: FloatArray?,
val coeffsCg: FloatArray?,
val frameMode: Char = ' '
)
// -------------------------------------------------------------------------
// GOP Unified decode (0x12 packet)
// -------------------------------------------------------------------------
/**
* Decode a GOP Unified packet.
* @param gopPayload Zstd-compressed unified block data
* @param header TAV header
* @param gopSize number of frames in this GOP
* @param frameCounter global frame counter at start of GOP (for grain synthesis RNG)
* @return list of RGBA8888 byte arrays, one per frame
*/
fun decodeGop(
gopPayload: ByteArray,
header: TavHeader,
gopSize: Int,
temporalLevels: Int = 2,
frameCounter: Int = 0
): List<ByteArray> {
val width = header.width
val height = header.height
val pixels = width * height
// Decompress
val decompressed = ZstdInputStream(ByteArrayInputStream(gopPayload)).use { it.readBytes() }
// Extract per-frame quantised coefficients
val quantisedCoeffs = decodeGopUnifiedBlock(decompressed, gopSize, pixels, header)
val gopWidth = header.width
val gopHeight = header.height
val gopY = Array(gopSize) { FloatArray(pixels) }
val gopCo = Array(gopSize) { FloatArray(pixels) }
val gopCg = Array(gopSize) { FloatArray(pixels) }
val subbands = calculateSubbandLayout(gopWidth, gopHeight, header.decompLevels)
// Dequantise with temporal scaling
for (t in 0 until gopSize) {
val temporalLevel = getTemporalSubbandLevel(t, gopSize, temporalLevels)
val temporalScale = getTemporalQuantiserScale(header.encoderPreset, temporalLevel)
val baseQY = kotlin.math.round(header.qY * temporalScale).toFloat().coerceIn(1.0f, 4096.0f)
val baseQCo = kotlin.math.round(header.qCo * temporalScale).toFloat().coerceIn(1.0f, 4096.0f)
val baseQCg = kotlin.math.round(header.qCg * temporalScale).toFloat().coerceIn(1.0f, 4096.0f)
if (header.isPerceptual) {
dequantisePerceptual(quantisedCoeffs[t][0], gopY[t], subbands, baseQY, false, header.encoderQuality, header.qY, header.decompLevels)
dequantisePerceptual(quantisedCoeffs[t][1], gopCo[t], subbands, baseQCo, true, header.encoderQuality, header.qY, header.decompLevels)
dequantisePerceptual(quantisedCoeffs[t][2], gopCg[t], subbands, baseQCg, true, header.encoderQuality, header.qY, header.decompLevels)
} else {
for (i in 0 until pixels) {
gopY[t][i] = quantisedCoeffs[t][0][i] * baseQY
gopCo[t][i] = quantisedCoeffs[t][1][i] * baseQCo
gopCg[t][i] = quantisedCoeffs[t][2][i] * baseQCg
}
}
}
// Grain synthesis on each GOP frame
for (t in 0 until gopSize) {
grainSynthesis(gopY[t], gopWidth, gopHeight, frameCounter + t, subbands, header.qY, header.encoderPreset)
}
// Inverse 3D DWT
DwtUtil.inverseMultilevel3D(gopY, gopWidth, gopHeight, gopSize, header.decompLevels, temporalLevels, header.waveletFilter, header.temporalMotionCoder)
DwtUtil.inverseMultilevel3D(gopCo, gopWidth, gopHeight, gopSize, header.decompLevels, temporalLevels, header.waveletFilter, header.temporalMotionCoder)
DwtUtil.inverseMultilevel3D(gopCg, gopWidth, gopHeight, gopSize, header.decompLevels, temporalLevels, header.waveletFilter, header.temporalMotionCoder)
// Convert each frame to RGBA
return (0 until gopSize).map { t ->
ycocgrToRgba(gopY[t], gopCo[t], gopCg[t], width, height, header.channelLayout)
}
}
/** Decode unified GOP block to per-frame per-channel ShortArrays. */
private fun decodeGopUnifiedBlock(
data: ByteArray, numFrames: Int, numPixels: Int, header: TavHeader
): Array<Array<ShortArray>> {
val output = Array(numFrames) { Array(3) { ShortArray(numPixels) } }
if (header.entropyCoder == 1) {
// EZBC: [frame_size(4)][frame_ezbc]...
var ptr2 = 0
for (frame in 0 until numFrames) {
if (ptr2 + 4 > data.size) break
val frameSize = readInt32LE(data, ptr2); ptr2 += 4
if (ptr2 + frameSize > data.size) break
EzbcDecode.decode2D(data, ptr2, header.channelLayout,
output[frame][0], output[frame][1], output[frame][2], null)
ptr2 += frameSize
}
} else {
// 2-bit significance map (legacy), all frames concatenated
decodeSigMapGop(data, numFrames, numPixels, header.channelLayout, output)
}
return output
}
private fun decodeSigMapGop(
data: ByteArray, numFrames: Int, numPixels: Int, channelLayout: Int,
output: Array<Array<ShortArray>>
) {
val hasY = (channelLayout and 4) == 0
val hasCoCg = (channelLayout and 2) == 0
val mapBytesPerFrame = (numPixels * 2 + 7) / 8
var readPtr = 0
val yMapsStart = if (hasY) { val s = readPtr; readPtr += mapBytesPerFrame * numFrames; s } else -1
val coMapsStart = if (hasCoCg) { val s = readPtr; readPtr += mapBytesPerFrame * numFrames; s } else -1
val cgMapsStart = if (hasCoCg) { val s = readPtr; readPtr += mapBytesPerFrame * numFrames; s } else -1
var yOthers = 0; var coOthers = 0; var cgOthers = 0
fun countOthers(mapsStart: Int): Int {
var cnt = 0
for (frame in 0 until numFrames) {
val frameMapOffset = frame * mapBytesPerFrame
for (i in 0 until numPixels) {
val bitPos = i * 2; val byteIdx = bitPos / 8; val bitOffset = bitPos % 8
val byteVal = data.getOrElse(mapsStart + frameMapOffset + byteIdx) { 0 }.toInt() and 0xFF
var code = (byteVal shr bitOffset) and 0x03
if (bitOffset == 7 && byteIdx + 1 < mapBytesPerFrame) {
val nb = data.getOrElse(mapsStart + frameMapOffset + byteIdx + 1) { 0 }.toInt() and 0xFF
code = (code and 0x01) or ((nb and 0x01) shl 1)
}
if (code == 3) cnt++
}
}
return cnt
}
if (hasY) yOthers = countOthers(yMapsStart)
if (hasCoCg) { coOthers = countOthers(coMapsStart); cgOthers = countOthers(cgMapsStart) }
val yValStart = readPtr; readPtr += yOthers * 2
val coValStart = readPtr; readPtr += coOthers * 2
val cgValStart = readPtr
var yVIdx = 0; var coVIdx = 0; var cgVIdx = 0
for (frame in 0 until numFrames) {
val frameMapOffset = frame * mapBytesPerFrame
for (i in 0 until numPixels) {
val bitPos = i * 2; val byteIdx = bitPos / 8; val bitOffset = bitPos % 8
fun getCode(mapsStart: Int): Int {
val byteVal = data.getOrElse(mapsStart + frameMapOffset + byteIdx) { 0 }.toInt() and 0xFF
var code = (byteVal shr bitOffset) and 0x03
if (bitOffset == 7 && byteIdx + 1 < mapBytesPerFrame) {
val nb = data.getOrElse(mapsStart + frameMapOffset + byteIdx + 1) { 0 }.toInt() and 0xFF
code = (code and 0x01) or ((nb and 0x01) shl 1)
}
return code
}
fun readVal(valStart: Int, vIdx: Int): Short {
val vp = valStart + vIdx * 2
return if (vp + 1 < data.size) {
val lo = data[vp ].toInt() and 0xFF
val hi = data[vp+1].toInt()
((hi shl 8) or lo).toShort()
} else 0
}
if (hasY) {
output[frame][0][i] = when (getCode(yMapsStart)) {
1 -> 1; 2 -> (-1).toShort(); 3 -> { val v = readVal(yValStart, yVIdx); yVIdx++; v }; else -> 0
}
}
if (hasCoCg) {
output[frame][1][i] = when (getCode(coMapsStart)) {
1 -> 1; 2 -> (-1).toShort(); 3 -> { val v = readVal(coValStart, coVIdx); coVIdx++; v }; else -> 0
}
output[frame][2][i] = when (getCode(cgMapsStart)) {
1 -> 1; 2 -> (-1).toShort(); 3 -> { val v = readVal(cgValStart, cgVIdx); cgVIdx++; v }; else -> 0
}
}
}
}
}
}