Update rendering pipeline
Build and Test a3270 / Build Android APK (push) Successful in 1m59s

This commit is contained in:
2026-09-28 02:17:46 +00:00
parent 79ce0a7dc6
commit 4754732642
7 changed files with 268 additions and 40 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId "haus.nightmare.a3270"
minSdk 24
targetSdk 34
versionCode 8
versionName "1.2.3"
versionCode 9
versionName "1.2.4"
}
buildTypes {
+2 -2
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
android:versionCode="8"
android:versionName="1.2.3">
android:versionCode="9"
android:versionName="1.2.4">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
@@ -555,7 +555,7 @@ fun MainScreen(
val fieldStatus = remember(screenBuffer, cursorAddr, screenVersion) {
val buf = screenBuffer
if (buf != null && buf.isFormatted) {
val cell = buf.getCell(cursorAddr)
val cell = buf.getDisplayCell(cursorAddr)
val isDbcs = (cell != null && (cell.db == haus.nightmare.lib3270j.screen.ExtendedAttribute.DB_LEFT ||
cell.db == haus.nightmare.lib3270j.screen.ExtendedAttribute.DB_RIGHT ||
cell.cs == haus.nightmare.lib3270j.screen.ExtendedAttribute.CS_DBCS))
@@ -481,9 +481,12 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
}
val c = client
if (c != null) {
_cursorAddress.value = c.screenBuffer.cursorAddress
_cursorAddress.value = c.screenBuffer.displayCursorAddress
val snapVersion = c.screenBuffer.snapshotVersion
_screenVersion.value = if (snapVersion > 0) snapVersion else System.currentTimeMillis()
} else {
_screenVersion.value = System.currentTimeMillis()
}
_screenVersion.value = System.currentTimeMillis()
}
}
}
@@ -1096,16 +1099,26 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
val buf = newClient.screenBuffer
_screenBuffer.value = buf
if (buf != null) {
_rows.value = buf.rows
_cols.value = buf.cols
_cursorAddress.value = buf.cursorAddress
val snap = buf.displaySnapshot
if (snap != null) {
_rows.value = snap.rows
_cols.value = snap.cols
_cursorAddress.value = snap.cursorAddress
_screenVersion.value = snap.version
} else {
_rows.value = buf.displayRows
_cols.value = buf.displayCols
_cursorAddress.value = buf.displayCursorAddress
_screenVersion.value = buf.snapshotVersion
}
} else {
_screenVersion.value = System.currentTimeMillis()
}
_isKeyboardLocked.value = newClient.inputProcessor.isKeyboardLocked
_isInsertMode.value = newClient.isInsertMode
_isDocMode.value = newClient.isDocMode
_isWordWrap.value = newClient.isWordWrap
_isAplMode.value = newClient.isAplMode
_screenVersion.value = System.currentTimeMillis()
// Drive CUT mode file transfers if active
fileTransferCoordinator?.onScreenUpdated()
@@ -1478,9 +1491,10 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
private fun getLineText(buf: ScreenBuffer, row: Int, cols: Int): String {
val sb = StringBuilder()
val snap = buf.displaySnapshot
for (c in 0 until cols) {
val addr = row * cols + c
val cell = buf.getCell(addr)
val cell = snap?.getCell(addr) ?: buf.getDisplayCell(addr)
if (cell != null && !cell.isFieldAttribute) {
val ch = cell.ucs4
if (ch in '!'..'~' || ch.code > 127) {
@@ -1498,9 +1512,10 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
private fun computeScreenContentHash(buf: ScreenBuffer?): Int {
if (buf == null) return 0
val sb = StringBuilder()
val total = buf.rows * buf.cols
val snap = buf.displaySnapshot
val total = (snap?.rows ?: buf.displayRows) * (snap?.cols ?: buf.displayCols)
for (i in 0 until total) {
val cell = buf.getCell(i)
val cell = snap?.getCell(i) ?: buf.getDisplayCell(i)
if (cell != null && !cell.isFieldAttribute && cell.ucs4 > ' ' && cell.ucs4.code != 0xFFFF) {
sb.append(cell.ucs4)
}
@@ -285,6 +285,7 @@ fun ScreenExporterDialog(
private fun generatePlainText(screenBuffer: ScreenBuffer?, rows: Int, cols: Int, maskHidden: Boolean): String {
if (screenBuffer == null) return ""
screenBuffer.updateDisplaySnapshot()
val sb = StringBuilder()
var currentHidden = false
@@ -292,7 +293,7 @@ private fun generatePlainText(screenBuffer: ScreenBuffer?, rows: Int, cols: Int,
val rowSb = StringBuilder()
for (c in 0 until cols) {
val addr = r * cols + c
val cell = screenBuffer.getCell(addr)
val cell = screenBuffer.getDisplayCell(addr) ?: continue
if (cell.fa.toInt() != 0) {
val faVal = cell.fa.toInt() and 0xFF
currentHidden = (faVal and FA_INT_HIGH_SEL) == 0
@@ -317,6 +318,7 @@ private fun generatePlainText(screenBuffer: ScreenBuffer?, rows: Int, cols: Int,
private fun generateHtml(screenBuffer: ScreenBuffer?, rows: Int, cols: Int, maskHidden: Boolean): String {
if (screenBuffer == null) return "<html><body><pre></pre></body></html>"
screenBuffer.updateDisplaySnapshot()
val sb = StringBuilder()
sb.append("<!DOCTYPE html>\n<html>\n<head>\n")
sb.append("<meta charset=\"UTF-8\">\n")
@@ -343,7 +345,7 @@ private fun generateHtml(screenBuffer: ScreenBuffer?, rows: Int, cols: Int, mask
for (r in 0 until rows) {
for (c in 0 until cols) {
val addr = r * cols + c
val cell = screenBuffer.getCell(addr)
val cell = screenBuffer.getDisplayCell(addr) ?: continue
if (cell.fa.toInt() != 0) {
val faVal = cell.fa.toInt() and 0xFF
currentProt = (faVal and FA_PROTECT) != 0
@@ -430,6 +432,7 @@ private fun generateBitmap(
canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), bgPaint)
if (screenBuffer == null) return bitmap
screenBuffer.updateDisplaySnapshot()
// Layer 1: Vector graphics plane (under text)
if (graphicsPlane != null && graphicsPlane.hasContent()) {
@@ -457,7 +460,7 @@ private fun generateBitmap(
for (r in 0 until rows) {
for (c in 0 until cols) {
val addr = r * cols + c
val cell = screenBuffer.getCell(addr)
val cell = screenBuffer.getDisplayCell(addr) ?: continue
val left = (c * charWidth).toFloat()
val top = (r * charHeight).toFloat()
@@ -219,7 +219,7 @@ fun TerminalView(
if (buf != null && buf.isFormatted) {
val faPos = buf.findFieldAttribute(clickAddr)
if (faPos >= 0) {
val fa = buf.getCell(faPos).fa.toInt() and 0xFF
val fa = buf.getDisplayCell(faPos)?.fa?.toInt()?.and(0xFF) ?: 0
if (faIsSelectable(fa) && !faIsZero(fa)) {
onLightPenSelect?.invoke(clickAddr)
isDetectable = true
@@ -232,7 +232,7 @@ fun TerminalView(
var addr = clickAddr
if (buf != null && buf.isFormatted) {
val faVal = buf.getFieldAttributeAt(addr)
if (faIsProtected(faVal.toInt() and 0xFF) || buf.getCell(addr).isFieldAttribute) {
if (faIsProtected(faVal.toInt() and 0xFF) || buf.getDisplayCell(addr)?.isFieldAttribute == true) {
addr = buf.findNextUnprotected(addr)
}
}
@@ -247,10 +247,15 @@ fun TerminalView(
}
) {
val version = currentScreenVersion
val activeCursorAddr = currentCursorAddr
if (version == -1L) return@Canvas
val metrics = calculateGridMetrics(size.width, size.height, cols, rows)
val buf = screenBuffer
val snapshot = buf?.displaySnapshot
val snapRows = snapshot?.rows ?: (buf?.displayRows ?: rows)
val snapCols = snapshot?.cols ?: (buf?.displayCols ?: cols)
val activeCursorAddr = snapshot?.cursorAddress ?: currentCursorAddr
val metrics = calculateGridMetrics(size.width, size.height, snapCols, snapRows)
if (metrics.cellWidth <= 0f || metrics.cellHeight <= 0f) return@Canvas
val cellWidth = metrics.cellWidth
@@ -272,8 +277,7 @@ fun TerminalView(
paint.textSize = baseTextSize * (cellWidth * 0.85f / measuredW)
}
val buf = screenBuffer
val totalCells = rows * cols
val totalCells = snapRows * snapCols
var currentFA: Byte = 0
var currentFieldEa: ExtendedAttribute? = null
@@ -287,10 +291,10 @@ fun TerminalView(
val start = selectionStart
val end = selectionEnd
if (start != null && end != null && cellWidth > 0f && cellHeight > 0f) {
val startCol = ((start.x - offsetX) / cellWidth).toInt().coerceIn(0, cols - 1)
val startRow = ((start.y - offsetY) / cellHeight).toInt().coerceIn(0, rows - 1)
val endCol = ((end.x - offsetX) / cellWidth).toInt().coerceIn(0, cols - 1)
val endRow = ((end.y - offsetY) / cellHeight).toInt().coerceIn(0, rows - 1)
val startCol = ((start.x - offsetX) / cellWidth).toInt().coerceIn(0, snapCols - 1)
val startRow = ((start.y - offsetY) / cellHeight).toInt().coerceIn(0, snapRows - 1)
val endCol = ((end.x - offsetX) / cellWidth).toInt().coerceIn(0, snapCols - 1)
val endRow = ((end.y - offsetY) / cellHeight).toInt().coerceIn(0, snapRows - 1)
selMinRow = minOf(startRow, endRow)
selMaxRow = maxOf(startRow, endRow)
@@ -317,11 +321,11 @@ fun TerminalView(
}
// 2. Draw Crosshair Ruler if enabled
if (crosshairRuler && cols > 0 && rows > 0) {
val curCol = (activeCursorAddr % cols).coerceIn(0, cols - 1)
val curRow = ((activeCursorAddr / cols) % rows).coerceIn(0, rows - 1)
val gridW = cols * cellWidth
val gridH = rows * cellHeight
if (crosshairRuler && snapCols > 0 && snapRows > 0) {
val curCol = (activeCursorAddr % snapCols).coerceIn(0, snapCols - 1)
val curRow = ((activeCursorAddr / snapCols) % snapRows).coerceIn(0, snapRows - 1)
val gridW = snapCols * cellWidth
val gridH = snapRows * cellHeight
val cx = offsetX + curCol * cellWidth
val cy = offsetY + curRow * cellHeight
@@ -337,9 +341,9 @@ fun TerminalView(
)
}
for (r in 0 until rows) {
for (c in 0 until cols) {
val addr = r * cols + c
for (r in 0 until snapRows) {
for (c in 0 until snapCols) {
val addr = r * snapCols + c
if (addr >= totalCells) break
val left = offsetX + c * cellWidth
@@ -355,7 +359,7 @@ fun TerminalView(
var csVal = 0
var ecVal = 0
val ea = if (buf != null) buf.getCell(addr) else null
val ea = snapshot?.getCell(addr) ?: buf?.getDisplayCell(addr)
if (ea != null) {
if (ea.isFieldAttribute) {
@@ -521,7 +525,7 @@ fun TerminalView(
}
// Field Outlining (0xC2 / TY_FOUTLINE)
val cellEa = if (buf != null) buf.getCell(addr) else null
val cellEa = snapshot?.getCell(addr) ?: buf?.getDisplayCell(addr)
var outline = if (cellEa != null) cellEa.ol.toInt() and 0xFF else 0
val fEa = currentFieldEa
val olUnderline = ExtendedAttribute.OL_UNDERLINE.toInt() and 0xFF
@@ -536,11 +540,13 @@ fun TerminalView(
val size = totalCells
val prevAddr = (addr - 1 + size) % size
if (buf != null && (buf.getDisplayCell(prevAddr)?.isFieldAttribute == true || c == 0)) {
val prevCell = snapshot?.getCell(prevAddr) ?: buf?.getDisplayCell(prevAddr)
if (prevCell?.isFieldAttribute == true || c == 0) {
if ((fol and olLeft) != 0) outline = outline or olLeft
}
val nextAddr = (addr + 1) % size
if (buf != null && (buf.getDisplayCell(nextAddr)?.isFieldAttribute == true || c == cols - 1)) {
val nextCell = snapshot?.getCell(nextAddr) ?: buf?.getDisplayCell(nextAddr)
if (nextCell?.isFieldAttribute == true || c == snapCols - 1) {
if ((fol and olRight) != 0) outline = outline or olRight
}
}
@@ -889,7 +895,7 @@ private fun copySelection(
for (c in colStart..colEnd) {
val addr = r * cols + c
if (addr < (rows * cols)) {
val cell = screenBuffer.getCell(addr)
val cell = screenBuffer.getDisplayCell(addr)
if (cell != null && !cell.isFieldAttribute) {
if (cell.db == ExtendedAttribute.DB_RIGHT) {
val isSubsequent = if (blockSelectMode) c > minCol else (addr > (minRow * cols + minCol))
@@ -0,0 +1,204 @@
package haus.nightmare.a3270
import haus.nightmare.lib3270j.TerminalModel
import haus.nightmare.lib3270j.charset.EbcdicTranslator
import haus.nightmare.lib3270j.screen.ExtendedAttribute
import haus.nightmare.lib3270j.screen.ScreenBuffer
import org.junit.Assert.*
import org.junit.Test
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
class DisplaySnapshotIntegrationTest {
@Test
fun testSnapshotCaptureAndLockFreeReads() {
val translator = EbcdicTranslator()
val sb = ScreenBuffer(TerminalModel.IBM_3279_2, translator)
sb.erase(false)
val initialVersion = sb.snapshotVersion
sb.getCell(0).ucs4 = 'H'
sb.getCell(1).ucs4 = 'E'
sb.getCell(2).ucs4 = 'L'
sb.getCell(3).ucs4 = 'L'
sb.getCell(4).ucs4 = 'O'
sb.setCursorAddress(42)
sb.updateDisplaySnapshot()
val snap = sb.displaySnapshot
assertNotNull("DisplaySnapshot must not be null after updateDisplaySnapshot()", snap)
assertTrue("Snapshot version must increase", sb.snapshotVersion > initialVersion)
assertEquals(24, snap.rows)
assertEquals(80, snap.cols)
assertEquals(42, snap.cursorAddress)
assertEquals('H', snap.getCell(0)?.ucs4)
assertEquals('E', snap.getCell(1)?.ucs4)
assertEquals('L', snap.getCell(2)?.ucs4)
assertEquals('L', snap.getCell(3)?.ucs4)
assertEquals('O', snap.getCell(4)?.ucs4)
// getDisplayCell fallback
assertEquals('H', sb.getDisplayCell(0)?.ucs4)
assertEquals(42, sb.displayCursorAddress)
assertEquals(24, sb.displayRows)
assertEquals(80, sb.displayCols)
}
@Test
fun testTearFreeRenderingUnderConcurrentBufferMutation() {
val translator = EbcdicTranslator()
val sb = ScreenBuffer(TerminalModel.IBM_3279_2, translator)
sb.erase(false)
val size = 24 * 80
val totalUpdates = 2000
val workerCount = 4
val executor = Executors.newFixedThreadPool(workerCount + 1)
val running = AtomicBoolean(true)
val tornCellCount = AtomicInteger(0)
val exceptionCount = AtomicInteger(0)
val totalReads = AtomicLong(0)
val startLatch = CountDownLatch(1)
val writersDoneLatch = CountDownLatch(1)
// Reader workers simulating Compose Canvas draw passes
for (w in 0 until workerCount) {
executor.submit {
try {
startLatch.await()
while (running.get()) {
val snap = sb.displaySnapshot ?: continue
val snapSize = snap.rows * snap.cols
for (addr in 0 until snapSize) {
val cell = snap.getCell(addr) ?: continue
totalReads.incrementAndGet()
// Patterned validation:
// ucs4 = 'A' + mod, ec = 0x10 + mod, fg = 0x30 + mod, bg = 0x50 + mod
if (cell.ucs4 in 'A'..'Z') {
val ucsOffset = cell.ucs4 - 'A'
val ecOffset = (cell.ec.toInt() and 0xFF) - 0x10
val fgOffset = (cell.fg.toInt() and 0xFF) - 0x30
val bgOffset = (cell.bg.toInt() and 0xFF) - 0x50
if (ucsOffset != ecOffset || ucsOffset != fgOffset || ucsOffset != bgOffset) {
tornCellCount.incrementAndGet()
}
}
}
}
} catch (t: Throwable) {
exceptionCount.incrementAndGet()
}
}
}
// Writer task simulating background Telnet network receiver thread
executor.submit {
try {
startLatch.await()
for (iter in 0 until totalUpdates) {
val mod = iter % 26
val ch = ('A' + mod)
val ec = (0x10 + mod).toByte()
val fg = (0x30 + mod).toByte()
val bg = (0x50 + mod).toByte()
val rawBuf = sb.buffer
for (i in 0 until size) {
val cell = rawBuf[i]
cell.ucs4 = ch
cell.ec = ec
cell.fg = fg
cell.bg = bg
}
sb.setCursorAddress(iter % size)
sb.updateDisplaySnapshot()
}
} catch (t: Throwable) {
exceptionCount.incrementAndGet()
} finally {
writersDoneLatch.countDown()
}
}
startLatch.countDown()
val finished = writersDoneLatch.await(10, TimeUnit.SECONDS)
assertTrue("Writer must complete updates without deadlock", finished)
running.set(false)
executor.shutdown()
assertTrue("Executor must terminate cleanly", executor.awaitTermination(5, TimeUnit.SECONDS))
assertEquals("Zero exceptions must occur during concurrent execution", 0, exceptionCount.get())
assertEquals("Zero torn cell states must occur during DisplaySnapshot access", 0, tornCellCount.get())
assertTrue("Total reads must be significant", totalReads.get() > 50_000)
}
@Test
fun testConcurrentReadsDuringScreenResizing() {
val translator = EbcdicTranslator()
val sb = ScreenBuffer(24, 80, 27, 132, translator)
val workerCount = 4
val executor = Executors.newFixedThreadPool(workerCount + 1)
val running = AtomicBoolean(true)
val exceptionCount = AtomicInteger(0)
val successfulReads = AtomicLong(0)
val startLatch = CountDownLatch(1)
val writerLatch = CountDownLatch(1)
for (w in 0 until workerCount) {
executor.submit {
try {
startLatch.await()
while (running.get()) {
val snap = sb.displaySnapshot ?: continue
val total = snap.rows * snap.cols
for (i in 0 until total) {
val cell = snap.getCell(i)
if (cell != null) {
successfulReads.incrementAndGet()
}
}
}
} catch (t: Throwable) {
exceptionCount.incrementAndGet()
}
}
}
// Writer alternates dimensions between 24x80 and 27x132
executor.submit {
try {
startLatch.await()
for (iter in 0 until 500) {
sb.erase((iter % 2) != 0)
}
} catch (t: Throwable) {
exceptionCount.incrementAndGet()
} finally {
writerLatch.countDown()
}
}
startLatch.countDown()
assertTrue("Resize writer must complete without deadlock", writerLatch.await(10, TimeUnit.SECONDS))
running.set(false)
executor.shutdown()
assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS))
assertEquals("Zero exceptions must occur during dynamic dimension resizing", 0, exceptionCount.get())
assertTrue("Successful reads across dimension resizing", successfulReads.get() > 20_000)
}
}