Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .jules/bolt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
## 2025-05-15 - ZRLE Decoder Architectural Optimization
**Learning:** In mobile/Android ARM environments, integer division and modulo in tight graphics loops are significant bottlenecks. Combining this with per-rectangle object allocation and small (4KB) decompression buffers limits the throughput of X11/VNC rendering.
**Action:** Always optimize RLE loops by using incremental counters instead of division/modulo, use at least 16KB buffers for JNI-bridged zlib operations, and use java.util.Arrays.fill for bulk pixel spans. Inlining stream helper classes into the main decoder eliminates allocation overhead for high-frequency rectangle updates.
199 changes: 110 additions & 89 deletions app/src/main/java/com/excp/podroid/x11/ZrleDecoder.kt
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,11 @@ class ZrleDecoder {
}

// Scratch buffer for compressed input read from the socket.
private var inputScratch = ByteArray(4096)
private var inputScratch = ByteArray(16384)
// Decompressed output buffer; re-used across inflate calls within one decode() call.
private var outputBuf = ByteArray(4096)
private var zBuf = ByteArray(16384)
private var zPos = 0
private var zAvail = 0

// Remaining compressed bytes in the current rect that have not yet been fed to the inflater.
private var remaining = 0
Expand All @@ -66,6 +68,8 @@ class ZrleDecoder {
inflater.reset()
remaining = 0
inputStream = null
zPos = 0
zAvail = 0
}

/**
Expand All @@ -92,9 +96,8 @@ class ZrleDecoder {
// compLen) so it never crosses the rect boundary.
remaining = compLen
inputStream = din

// Wrap the inflater so tile-level code just calls readByte()/readBytes().
val zi = ZInput(inflater)
zPos = 0
zAvail = 0

// Tile loop: 64x64 tiles in row-major order.
var ty = 0
Expand All @@ -103,37 +106,37 @@ class ZrleDecoder {
var tx = 0
while (tx < w) {
val tw = minOf(64, w - tx)
decodeTile(zi, x + tx, y + ty, tw, th, target, stride)
decodeTile(x + tx, y + ty, tw, th, target, stride)
tx += 64
}
ty += 64
}
}

private fun decodeTile(zi: ZInput, tx: Int, ty: Int, tw: Int, th: Int, target: IntArray, stride: Int) {
val subenc = zi.readByte()
private fun decodeTile(tx: Int, ty: Int, tw: Int, th: Int, target: IntArray, stride: Int) {
val subenc = readByte()
when {
subenc == 0 -> {
// Raw: tw*th CPIXELs.
for (row in 0 until th) {
val base = (ty + row) * stride + tx
for (col in 0 until tw) {
target[base + col] = zi.readCpixel()
target[base + col] = readCpixel()
}
}
}
subenc == 1 -> {
// Solid: 1 CPIXEL, fill the whole tile.
val color = zi.readCpixel()
val color = readCpixel()
for (row in 0 until th) {
val base = (ty + row) * stride + tx
for (col in 0 until tw) target[base + col] = color
java.util.Arrays.fill(target, base, base + tw, color)
}
}
subenc in 2..16 -> {
// Packed palette.
val n = subenc
val palette = IntArray(n) { zi.readCpixel() }
val palette = IntArray(n) { readCpixel() }
val bitsPerIndex = when {
n == 2 -> 1
n <= 4 -> 2
Expand All @@ -147,7 +150,7 @@ class ZrleDecoder {
var bitsInAccum = 0
while (col < tw) {
if (bitsInAccum == 0) {
accumByte = zi.readByte()
accumByte = readByte()
bitsInAccum = 8
}
val idx = (accumByte ushr (8 - bitsPerIndex)) and ((1 shl bitsPerIndex) - 1)
Expand All @@ -167,118 +170,136 @@ class ZrleDecoder {
// Plain RLE: sequence of runs until tile is full.
val total = tw * th
var filled = 0
var currRow = 0
var currCol = 0
while (filled < total) {
val color = zi.readCpixel()
val runLen = zi.readRunLength()
val color = readCpixel()
var runLen = readRunLength()
if (filled + runLen > total) throw IOException("ZRLE: plain RLE run overruns tile ($filled+$runLen > $total)")
repeat(runLen) {
val pos = filled + it
val row = pos / tw; val col = pos % tw
target[(ty + row) * stride + (tx + col)] = color
}
filled += runLen
Comment on lines 175 to 179
while (runLen > 0) {
val remInRow = tw - currCol
val n = minOf(runLen, remInRow)
java.util.Arrays.fill(target, (ty + currRow) * stride + tx + currCol, (ty + currRow) * stride + tx + currCol + n, color)
currCol += n
if (currCol == tw) {
currCol = 0
currRow++
}
runLen -= n
}
}
}
subenc in 130..255 -> {
// Palette RLE.
val n = subenc - 128
val palette = IntArray(n) { zi.readCpixel() }
val palette = IntArray(n) { readCpixel() }
val total = tw * th
var filled = 0
var currRow = 0
var currCol = 0
while (filled < total) {
val indexByte = zi.readByte()
val indexByte = readByte()
if (indexByte and 0x80 == 0) {
// Single pixel.
if (indexByte >= n) throw IOException("ZRLE: palette RLE index $indexByte >= $n")
val pos = filled
val row = pos / tw; val col = pos % tw
target[(ty + row) * stride + (tx + col)] = palette[indexByte]
target[(ty + currRow) * stride + tx + currCol] = palette[indexByte]
filled++
currCol++
if (currCol == tw) {
currCol = 0
currRow++
}
} else {
// Run of palette[index & 0x7F].
val idx = indexByte and 0x7F
if (idx >= n) throw IOException("ZRLE: palette RLE index $idx >= $n")
val color = palette[idx]
val runLen = zi.readRunLength()
var runLen = readRunLength()
if (filled + runLen > total) throw IOException("ZRLE: palette RLE run overruns tile ($filled+$runLen > $total)")
repeat(runLen) {
val pos = filled + it
val row = pos / tw; val col = pos % tw
target[(ty + row) * stride + (tx + col)] = color
}
filled += runLen
while (runLen > 0) {
val remInRow = tw - currCol
val len = minOf(runLen, remInRow)
java.util.Arrays.fill(target, (ty + currRow) * stride + tx + currCol, (ty + currRow) * stride + tx + currCol + len, color)
currCol += len
if (currCol == tw) {
currCol = 0
currRow++
}
runLen -= len
}
}
}
}
else -> throw IOException("ZRLE: unsupported subencoding $subenc")
}
}

/**
* Thin wrapper around [Inflater] that provides byte-level and CPIXEL reads.
* The inflater's input was already loaded by [decode]; this just drains output.
*/
private inner class ZInput(private val inf: Inflater) {
private val buf = ByteArray(256)
private var pos = 0
private var avail = 0

private fun fill() {
while (avail == 0) {
if (inf.finished()) throw IOException("ZRLE: inflater finished early")
// Feed the next compressed chunk on demand, bounded by this rect's
// remaining budget. Each chunk is fully consumed before its buffer
// is reused, and we never read past compLen.
if (inf.needsInput() && remaining > 0) {
val nIn = minOf(remaining, inputScratch.size)
inputStream!!.readFully(inputScratch, 0, nIn)
inf.setInput(inputScratch, 0, nIn)
remaining -= nIn
}
val n = inf.inflate(buf)
if (n > 0) { pos = 0; avail = n }
// n == 0 with needsInput means all input was consumed; if finished() is false
// but no output and needsInput, the caller overfed or the stream is malformed.
else if (inf.needsInput()) throw IOException("ZRLE: inflater needs more input but none queued")
// n == 0, not finished, not needsInput → needsDictionary or a stuck
// stream: bail rather than spin forever.
else throw IOException("ZRLE: inflater made no progress")
private fun fill() {
while (zAvail == 0) {
if (inflater.finished()) throw IOException("ZRLE: inflater finished early")
// Feed the next compressed chunk on demand, bounded by this rect's
// remaining budget. Each chunk is fully consumed before its buffer
// is reused, and we never read past compLen.
if (inflater.needsInput() && remaining > 0) {
val nIn = minOf(remaining, inputScratch.size)
inputStream!!.readFully(inputScratch, 0, nIn)
inflater.setInput(inputScratch, 0, nIn)
remaining -= nIn
}
val n = inflater.inflate(zBuf)
if (n > 0) {
zPos = 0; zAvail = n
}
// n == 0 with needsInput means all input was consumed; if finished() is false
// but no output and needsInput, the caller overfed or the stream is malformed.
else if (inflater.needsInput()) throw IOException("ZRLE: inflater needs more input but none queued")
// n == 0, not finished, not needsInput → needsDictionary or a stuck
// stream: bail rather than spin forever.
else throw IOException("ZRLE: inflater made no progress")
}
}

fun readByte(): Int {
fill()
return buf[pos++].toInt().also { avail-- } and 0xFF
}
private fun readByte(): Int {
fill()
return zBuf[zPos++].toInt().also { zAvail-- } and 0xFF
}

/** Read 3-byte CPIXEL (B, G, R) and return as ARGB int. */
fun readCpixel(): Int {
val b = readByte()
val g = readByte()
val r = readByte()
/** Read 3-byte CPIXEL (B, G, R) and return as ARGB int. */
private fun readCpixel(): Int {
if (zAvail >= 3) {
val b = zBuf[zPos++].toInt() and 0xFF
val g = zBuf[zPos++].toInt() and 0xFF
val r = zBuf[zPos++].toInt() and 0xFF
zAvail -= 3
return (0xFF shl 24) or (r shl 16) or (g shl 8) or b
}
val b = readByte()
val g = readByte()
val r = readByte()
return (0xFF shl 24) or (r shl 16) or (g shl 8) or b
}

/**
* Read a ZRLE run-length (sum-of-bytes + 1).
* Each byte 0xFF contributes 255 and reading continues;
* the first byte < 0xFF ends the sequence, contributing its value.
* The actual run count = sum + 1.
*
* The accumulator is capped at MAX_TILE_PIXELS (4096, the pixel count of
* the largest possible 64x64 tile) to prevent Int overflow: without the cap,
* enough 0xFF bytes would wrap the sum to a large negative number, defeating
* the caller's post-hoc (filled + runLen > total) overrun check.
*/
fun readRunLength(): Int {
var total = 0
while (true) {
val b = readByte()
total += b
if (total > MAX_TILE_PIXELS) throw IOException("ZRLE: run length $total exceeds max tile size")
if (b != 0xFF) break
}
return total + 1
/**
* Read a ZRLE run-length (sum-of-bytes + 1).
* Each byte 0xFF contributes 255 and reading continues;
* the first byte < 0xFF ends the sequence, contributing its value.
* The actual run count = sum + 1.
*
* The accumulator is capped at MAX_TILE_PIXELS (4096, the pixel count of
* the largest possible 64x64 tile) to prevent Int overflow: without the cap,
* enough 0xFF bytes would wrap the sum to a large negative number, defeating
* the caller's post-hoc (filled + runLen > total) overrun check.
*/
private fun readRunLength(): Int {
var total = 0
while (true) {
val b = readByte()
total += b
if (total > MAX_TILE_PIXELS) throw IOException("ZRLE: run length $total exceeds max tile size")
if (b != 0xFF) break
}
return total + 1
}
}