Files
tsvm/assets/disk0/tvdos/bin/playtev.js
2025-11-11 00:17:51 +09:00

915 lines
32 KiB
JavaScript

// Created by CuriousTorvald and Claude on 2025-08-18.
// TSVM Enhanced Video (TEV) Format Decoder - YCoCg-R 4:2:0 Version
// Usage: playtev moviefile.tev [options]
// Options: -i (interactive), -debug-mv (show motion vector debug visualization)
// -deinterlace=algorithm (yadif or bwdif, default: yadif)
// -nodeblock (disable post-processing deblocking filter)
// -boundaryaware (enable boundary-aware decoding to prevent artifacts at DCT level)
const WIDTH = 560
const HEIGHT = 448
const BLOCK_SIZE = 16 // 16x16 blocks for YCoCg-R
const TEV_MAGIC = [0x1F, 0x54, 0x53, 0x56, 0x4D, 0x54, 0x45, 0x56] // "\x1FTSVM TEV"
const TEV_VERSION_YCOCG = 2 // YCoCg-R version
const TEV_VERSION_ICtCp = 3 // ICtCp version
const SND_BASE_ADDR = audio.getBaseAddr()
const pcm = require("pcm")
const MP2_FRAME_SIZE = [144,216,252,288,360,432,504,576,720,864,1008,1152,1440,1728]
// Block encoding modes
const TEV_MODE_SKIP = 0x00
const TEV_MODE_INTRA = 0x01
const TEV_MODE_INTER = 0x02
const TEV_MODE_MOTION = 0x03
// Packet types
const TEV_PACKET_IFRAME = 0x10
const TEV_PACKET_PFRAME = 0x11
const TEV_PACKET_AUDIO_MP2 = 0x20
const TEV_PACKET_SUBTITLE = 0x30 // Legacy SSF (frame-locked)
const TEV_PACKET_SUBTITLE_TC = 0x31 // SSF-TC (timecode-based)
const TEV_PACKET_SYNC = 0xFF
// Subtitle opcodes (SSF format)
const SSF_OP_NOP = 0x00
const SSF_OP_SHOW = 0x01
const SSF_OP_HIDE = 0x02
const SSF_OP_MOVE = 0x03
const SSF_OP_UPLOAD_LOW_FONT = 0x80
const SSF_OP_UPLOAD_HIGH_FONT = 0x81
// Subtitle state
let subtitleVisible = false
let subtitleText = ""
let subtitlePosition = 0 // 0=bottom center (default)
// SSF-TC subtitle event buffer
let subtitleEvents = [] // Array of {timecode_ns, index, opcode, text}
let nextSubtitleEventIndex = 0 // Next event to check
// Parse command line options
let interactive = false
let debugMotionVectors = false
let deinterlaceAlgorithm = "yadif"
let enableDeblocking = false // Default: disabled (use -deblock to enable)
let enableBoundaryAwareDecoding = false // Default: disabled (use -boundaryaware to enable) // suitable for still frame and slide shows, absolutely unsuitable for videos
if (exec_args.length > 2) {
for (let i = 2; i < exec_args.length; i++) {
const arg = exec_args[i].toLowerCase()
if (arg === "-i") {
interactive = true
} else if (arg === "-debug-mv") {
debugMotionVectors = true
} else if (arg === "-deblock") {
enableDeblocking = true
} else if (arg === "-boundaryaware") {
enableBoundaryAwareDecoding = true
} else if (arg.startsWith("-deinterlace=")) {
deinterlaceAlgorithm = arg.substring(13)
}
}
}
const fullFilePath = _G.shell.resolvePathInput(exec_args[1])
const FILE_LENGTH = files.open(fullFilePath.full).size
let videoRateBin = []
let errorlevel = 0
let notifHideTimer = 0
const NOTIF_SHOWUPTIME = 3000000000
let [cy, cx] = con.getyx()
let gui = require("playgui")
let seqread = undefined
let fullFilePathStr = fullFilePath.full
// Select seqread driver to use
if (fullFilePathStr.startsWith('$:/TAPE') || fullFilePathStr.startsWith('$:\\TAPE')) {
seqread = require("seqreadtape")
seqread.prepare(fullFilePathStr)
seqread.seek(0)
} else {
seqread = require("seqread")
seqread.prepare(fullFilePathStr)
}
con.clear()
con.curs_set(0)
graphics.setGraphicsMode(4) // 4096-color mode
graphics.clearPixels(0)
graphics.clearPixels2(0)
// Initialize audio
audio.resetParams(0)
audio.purgeQueue(0)
audio.setPcmMode(0)
audio.setMasterVolume(0, 255)
// set colour zero as half-opaque black
graphics.setPalette(0, 0, 0, 0, 9)
// Subtitle display functions
function clearSubtitleArea() {
// Clear the subtitle area at the bottom of the screen
// Text mode is 80x32, so clear the bottom few lines
let oldFgColor = con.get_color_fore()
let oldBgColor = con.get_color_back()
con.color_pair(255, 255) // transparent to clear
// Clear bottom 4 lines for subtitles
for (let row = 29; row <= 32; row++) {
con.move(row, 1)
for (let col = 1; col <= 80; col++) {
print(" ")
}
}
con.color_pair(oldFgColor, oldBgColor)
}
function getVisualLength(line) {
// Calculate the visual length of a line excluding formatting tags
let visualLength = 0
let i = 0
while (i < line.length) {
if (i < line.length - 2 && line[i] === '<') {
// Check for formatting tags and skip them
if (line.substring(i, i + 3).toLowerCase() === '<b>' ||
line.substring(i, i + 3).toLowerCase() === '<i>') {
i += 3 // Skip tag
} else if (i < line.length - 3 &&
(line.substring(i, i + 4).toLowerCase() === '</b>' ||
line.substring(i, i + 4).toLowerCase() === '</i>')) {
i += 4 // Skip closing tag
} else {
// Not a formatting tag, count the character
visualLength++
i++
}
} else {
// Regular character, count it
visualLength++
i++
}
}
return visualLength
}
function displayFormattedLine(line) {
// Parse line and handle <b> and <i> tags with color changes
// Default subtitle color: yellow (231), formatted text: white (254)
let i = 0
let inBoldOrItalic = false
// insert initial padding block
con.color_pair(0, 255)
con.prnch(0xDE)
con.color_pair(231, 0)
while (i < line.length) {
if (i < line.length - 2 && line[i] === '<') {
// Check for opening tags
if (line.substring(i, i + 3).toLowerCase() === '<b>' ||
line.substring(i, i + 3).toLowerCase() === '<i>') {
con.color_pair(254, 0) // Switch to white for formatted text
inBoldOrItalic = true
i += 3
} else if (i < line.length - 3 &&
(line.substring(i, i + 4).toLowerCase() === '</b>' ||
line.substring(i, i + 4).toLowerCase() === '</i>')) {
con.color_pair(231, 0) // Switch back to yellow for normal text
inBoldOrItalic = false
i += 4
} else {
// Not a formatting tag, print the character
print(line[i])
i++
}
} else {
// Regular character, print it
print(line[i])
i++
}
}
// insert final padding block
con.color_pair(0, 255)
con.prnch(0xDD)
con.color_pair(231, 0)
}
function displaySubtitle(text, position = 0) {
if (!text || text.length === 0) {
clearSubtitleArea()
return
}
// Set subtitle colors: yellow (231) on black (0)
let oldFgColor = con.get_color_fore()
let oldBgColor = con.get_color_back()
con.color_pair(231, 0)
// Split text into lines
let lines = text.split('\n')
// Calculate position based on subtitle position setting
let startRow, startCol
// Calculate visual length without formatting tags for positioning
let longestLineLength = lines.map(s => getVisualLength(s)).sort().last()
switch (position) {
case 2: // center left
case 6: // center right
case 8: // dead center
startRow = 16 - Math.floor(lines.length / 2)
break
case 3: // top left
case 4: // top center
case 5: // top right
startRow = 2
break
case 0: // bottom center
case 1: // bottom left
case 7: // bottom right
default:
startRow = 32 - lines.length
startRow = 32 - lines.length
startRow = 32 - lines.length // Default to bottom center
}
// Display each line
for (let i = 0; i < lines.length; i++) {
let line = lines[i].trim()
if (line.length === 0) continue
let row = startRow + i
if (row < 1) row = 1
if (row > 32) row = 32
// Calculate column based on alignment
switch (position) {
case 1: // bottom left
case 2: // center left
case 3: // top left
startCol = 1
break
case 5: // top right
case 6: // center right
case 7: // bottom right
startCol = Math.max(1, 78 - getVisualLength(line) - 2)
break
case 0: // bottom center
case 4: // top center
case 8: // dead center
default:
startCol = Math.max(1, Math.floor((80 - longestLineLength - 2) / 2) + 1)
break
}
con.move(row, startCol)
// Parse and display line with formatting tag support
displayFormattedLine(line)
}
con.color_pair(oldFgColor, oldBgColor)
}
// Parse SSF-TC subtitle packet and add to event buffer (0x31)
function parseSubtitlePacketTC(packetSize) {
// Read subtitle index (24-bit, little-endian)
let indexByte0 = seqread.readOneByte()
let indexByte1 = seqread.readOneByte()
let indexByte2 = seqread.readOneByte()
let index = indexByte0 | (indexByte1 << 8) | (indexByte2 << 16)
// Read timecode (64-bit, little-endian)
let timecode_ns = 0
for (let i = 0; i < 8; i++) {
let byte = seqread.readOneByte()
timecode_ns += byte * Math.pow(2, i * 8)
}
// Read opcode
let opcode = seqread.readOneByte()
let remainingBytes = packetSize - 12 // Subtract 3 (index) + 8 (timecode) + 1 (opcode)
// Read text if present
let text = null
if (remainingBytes > 1 && (opcode === SSF_OP_SHOW || (opcode >= 0x10 && opcode <= 0x2F))) {
let textBytes = seqread.readBytes(remainingBytes)
text = ""
for (let i = 0; i < remainingBytes - 1; i++) { // -1 for null terminator
let byte = sys.peek(textBytes + i)
if (byte === 0) break
text += String.fromCharCode(byte)
}
sys.free(textBytes)
} else if (remainingBytes > 0) {
// Skip remaining bytes
let skipBytes = seqread.readBytes(remainingBytes)
sys.free(skipBytes)
}
// Add event to buffer
subtitleEvents.push({
timecode_ns: timecode_ns,
index: index,
opcode: opcode,
text: text
})
}
// Process subtitle events based on current playback time
function processSubtitleEvents(currentTimeNs) {
// Process all events whose timecode has been reached
while (nextSubtitleEventIndex < subtitleEvents.length) {
let event = subtitleEvents[nextSubtitleEventIndex]
if (event.timecode_ns > currentTimeNs) {
break // Haven't reached this event yet
}
// Execute the subtitle event
switch (event.opcode) {
case SSF_OP_SHOW:
subtitleText = event.text || ""
subtitleVisible = true
displaySubtitle(subtitleText, subtitlePosition)
break
case SSF_OP_HIDE:
subtitleVisible = false
subtitleText = ""
clearSubtitleArea()
break
case SSF_OP_MOVE:
if (event.text && event.text.length > 0) {
let newPosition = event.text.charCodeAt(0)
if (newPosition >= 0 && newPosition <= 8) {
subtitlePosition = newPosition
if (subtitleVisible && subtitleText.length > 0) {
clearSubtitleArea()
displaySubtitle(subtitleText, subtitlePosition)
}
}
}
break
case SSF_OP_UPLOAD_LOW_FONT:
case SSF_OP_UPLOAD_HIGH_FONT:
// Font upload handled during packet parsing
break
}
nextSubtitleEventIndex++
}
}
// Process legacy frame-locked subtitle packet (0x30)
function processSubtitlePacket(packetSize) {
// Read subtitle packet data according to SSF format
// uint24 index + uint8 opcode + variable arguments
let index = 0
// Read 24-bit index (little-endian)
let indexByte0 = seqread.readOneByte()
let indexByte1 = seqread.readOneByte()
let indexByte2 = seqread.readOneByte()
index = indexByte0 | (indexByte1 << 8) | (indexByte2 << 16)
let opcode = seqread.readOneByte()
let remainingBytes = packetSize - 4 // Subtract 3 bytes for index + 1 byte for opcode
switch (opcode) {
case SSF_OP_SHOW: {
// Read UTF-8 text until null terminator
if (remainingBytes > 1) {
let textBytes = seqread.readBytes(remainingBytes)
let textStr = ""
// Convert bytes to string, stopping at null terminator
for (let i = 0; i < remainingBytes - 1; i++) { // -1 for null terminator
let byte = sys.peek(textBytes + i)
if (byte === 0) break
textStr += String.fromCharCode(byte)
}
sys.free(textBytes)
subtitleText = textStr
subtitleVisible = true
displaySubtitle(subtitleText, subtitlePosition)
}
break
}
case SSF_OP_HIDE: {
subtitleVisible = false
subtitleText = ""
clearSubtitleArea()
break
}
case SSF_OP_MOVE: {
if (remainingBytes >= 2) { // Need at least 1 byte for position + 1 null terminator
let newPosition = seqread.readOneByte()
seqread.readOneByte() // Read null terminator
if (newPosition >= 0 && newPosition <= 7) {
subtitlePosition = newPosition
// Re-display current subtitle at new position if visible
if (subtitleVisible && subtitleText.length > 0) {
clearSubtitleArea()
displaySubtitle(subtitleText, subtitlePosition)
}
}
}
break
}
case SSF_OP_UPLOAD_LOW_FONT:
case SSF_OP_UPLOAD_HIGH_FONT: {
// Font upload - read payload length and font data
if (remainingBytes >= 3) { // uint16 length + at least 1 byte data
let payloadLen = seqread.readShort()
if (remainingBytes >= payloadLen + 2) {
let fontData = seqread.readBytes(payloadLen)
// upload font data
for (let i = 0; i < Math.min(payloadLen, 1920); i++) sys.poke(-1300607 - i, sys.peek(fontData + i))
sys.poke(-1299460, (opcode == SSF_OP_UPLOAD_LOW_FONT) ? 18 : 19)
sys.free(fontData)
}
}
break
}
case SSF_OP_NOP:
default: {
// Skip remaining bytes
if (remainingBytes > 0) {
let skipBytes = seqread.readBytes(remainingBytes)
sys.free(skipBytes)
}
if (interactive && opcode !== SSF_OP_NOP) {
serial.println(`[SUBTITLE UNKNOWN] Index: ${index}, Opcode: 0x${opcode.toString(16).padStart(2, '0')}`)
}
break
}
}
}
// Check magic number
let magic = seqread.readBytes(8)
let magicMatching = true
let actualMagic = []
TEV_MAGIC.forEach((b, i) => {
let testb = sys.peek(magic + i) & 255
actualMagic.push(testb)
if (testb != b) {
magicMatching = false
}
})
sys.free(magic)
if (!magicMatching) {
println("Not a TEV file (MAGIC mismatch) -- got " + actualMagic.join())
return 1
}
// Read header
let version = seqread.readOneByte()
if (version !== TEV_VERSION_YCOCG && version !== TEV_VERSION_ICtCp) {
println(`Unsupported TEV version: ${version} (expected ${TEV_VERSION_YCOCG} for YCoCg-R or ${TEV_VERSION_ICtCp} for ICtCp)`)
return 1
}
let colorSpace = (version === TEV_VERSION_ICtCp) ? "ICtCp" : "YCoCg"
if (interactive) {
con.move(1,1)
println(`Push and hold Backspace to exit | ${colorSpace} | Deblock: ${enableDeblocking ? 'ON' : 'OFF'} | EdgeAware: ${enableBoundaryAwareDecoding ? 'ON' : 'OFF'}`);
}
let width = seqread.readShort()
let height = seqread.readShort()
let fps = seqread.readOneByte()
let totalFrames = seqread.readInt()
let qualityY = seqread.readOneByte()
let qualityCo = seqread.readOneByte()
let qualityCg = seqread.readOneByte()
let flags = seqread.readOneByte()
let hasAudio = !!(flags & 1)
let hasSubtitle = !!(flags & 2)
let videoFlags = seqread.readOneByte()
let isInterlaced = !!(videoFlags & 1)
let isNTSC = !!(videoFlags & 2)
let unused2 = seqread.readOneByte()
serial.println(`Video metadata:`)
serial.println(` Frames: ${totalFrames}`)
serial.println(` FPS: ${(isNTSC) ? (fps * 1000 / 1001) : fps}`)
serial.println(` Duration: ${totalFrames / fps}`)
serial.println(` Audio: ${hasAudio ? "Yes" : "No"}`)
serial.println(` Resolution: ${width}x${height}, ${isInterlaced ? "interlaced" : "progressive"}`)
serial.println(` Quality: Y=${qualityY}, Co=${qualityCo}, Cg=${qualityCg}`)
// DEBUG interlace raw output
//if (isInterlaced) {
// height = height >> 1
// isInterlaced = false
//}
// END OF DEBUG
serial.println(`TEV Format ${version} (${colorSpace}); Q: ${qualityY} ${qualityCo} ${qualityCg}; Interlaced: ${isInterlaced ? 'Yes' : 'No'}`)
function updateDataRateBin(rate) {
videoRateBin.push(rate)
if (videoRateBin.length > fps) {
videoRateBin.shift()
}
}
function getVideoRate() {
let baseRate = videoRateBin.reduce((a, c) => a + c, 0)
let mult = fps / videoRateBin.length
return baseRate * mult
}
let FRAME_TIME = 1.0 / fps
let FRAME_TIME_NS = (1000000000.0 / fps) // Frame time in nanoseconds for subtitle timing
// Ultra-fast approach: always render to display, use dedicated previous frame buffer
const FRAME_PIXELS = width * height
// Frame buffer addresses for graphics display
const DISPLAY_RG_ADDR = -1048577 // Main graphics RG plane (displayed)
const DISPLAY_BA_ADDR = -1310721 // Main graphics BA plane (displayed)
// RGB frame buffers (24-bit: R,G,B per pixel)
const FRAME_SIZE = 560*448*3 // Total frame size = 752,640 bytes
// Ping-pong frame buffers to eliminate memcpy overhead
const RGB_BUFFER_A = sys.malloc(FRAME_SIZE)
const RGB_BUFFER_B = sys.malloc(FRAME_SIZE)
// Static Yadif deinterlacing buffers (half-height field buffers for interlaced mode)
const FIELD_SIZE = 560 * 224 * 3 // Half-height field buffer size
const CURR_FIELD_BUFFER = isInterlaced ? sys.malloc(FIELD_SIZE) : 0
const PREV_FIELD_BUFFER = isInterlaced ? sys.malloc(FIELD_SIZE) : 0
const NEXT_FIELD_BUFFER = isInterlaced ? sys.malloc(FIELD_SIZE) : 0 // For temporal prediction
// Ping-pong buffer pointers (swap instead of copy)
let CURRENT_RGB_ADDR = RGB_BUFFER_A
let PREV_RGB_ADDR = RGB_BUFFER_B
// Initialize RGB frame buffers to black (0,0,0)
sys.memset(RGB_BUFFER_A, 0, FRAME_PIXELS * 3)
sys.memset(RGB_BUFFER_B, 0, FRAME_PIXELS * 3)
// Initialize Yadif field buffers to black
sys.memset(CURR_FIELD_BUFFER, 0, FIELD_SIZE)
sys.memset(PREV_FIELD_BUFFER, 0, FIELD_SIZE)
sys.memset(NEXT_FIELD_BUFFER, 0, FIELD_SIZE)
// Initialize display framebuffer to black
sys.memset(DISPLAY_RG_ADDR, 0, FRAME_PIXELS) // Black in RG plane
sys.memset(DISPLAY_BA_ADDR, 15, FRAME_PIXELS) // Black with alpha=15 (opaque) in BA plane
let frameCount = 0
let trueFrameCount = 0
let stopPlay = false
let akku = FRAME_TIME
let akku2 = 0.0
// Frame buffering for temporal prediction (interlaced mode only)
let bufferedFrames = [] // Queue of decoded frames for temporal prediction
let frameBuffer1 = null // Current frame data
let frameBuffer2 = null // Previous frame data
let frameDisplayDelay = 1 // Display frames 1 frame delayed for temporal prediction
let mp2Initialised = false
let audioFired = false
// Performance tracking variables
let decompressTime = 0
let decodeTime = 0
let uploadTime = 0
let biasTime = 0
const BIAS_LIGHTING_MIN = 1.0 / 16.0
let oldBgcol = [BIAS_LIGHTING_MIN, BIAS_LIGHTING_MIN, BIAS_LIGHTING_MIN]
let notifHidden = false
function getRGBfromScr(x, y) {
let offset = y * WIDTH + x
let rg = sys.peek(-1048577 - offset)
let ba = sys.peek(-1310721 - offset)
return [(rg >>> 4) / 15.0, (rg & 15) / 15.0, (ba >>> 4) / 15.0]
}
function setBiasLighting() {
let samples = []
// Get native resolution for centering calculation
let nativeWidth = graphics.getPixelDimension()[0]
let nativeHeight = graphics.getPixelDimension()[1]
// Calculate video position offset (centered)
let offsetX = Math.floor((nativeWidth - width) / 2)
let offsetY = Math.floor((nativeHeight - height) / 2)
// Sample from video borders, scaled to actual video dimensions
let sampleStepX = Math.max(8, Math.floor(width / 18)) // ~18 samples across width
let sampleStepY = Math.max(8, Math.floor(height / 17)) // ~17 samples across height
let borderMargin = Math.min(8, Math.floor(width / 70)) // Proportional border margin
// Sample top and bottom borders
for (let x = borderMargin; x < width - borderMargin; x += sampleStepX) {
samples.push(getRGBfromScr(x + offsetX, borderMargin + offsetY))
samples.push(getRGBfromScr(x + offsetX, height - borderMargin - 1 + offsetY))
}
// Sample left and right borders
for (let y = borderMargin; y < height - borderMargin; y += sampleStepY) {
samples.push(getRGBfromScr(borderMargin + offsetX, y + offsetY))
samples.push(getRGBfromScr(width - borderMargin - 1 + offsetX, y + offsetY))
}
let out = [0.0, 0.0, 0.0]
samples.forEach(rgb=>{
out[0] += rgb[0]
out[1] += rgb[1]
out[2] += rgb[2]
})
out[0] = BIAS_LIGHTING_MIN + (out[0] / samples.length / 2.0) // darken a bit
out[1] = BIAS_LIGHTING_MIN + (out[1] / samples.length / 2.0)
out[2] = BIAS_LIGHTING_MIN + (out[2] / samples.length / 2.0)
let bgr = (oldBgcol[0]*5 + out[0]) / 6.0
let bgg = (oldBgcol[1]*5 + out[1]) / 6.0
let bgb = (oldBgcol[2]*5 + out[2]) / 6.0
oldBgcol = [bgr, bgg, bgb]
graphics.setBackground(Math.round(bgr * 255), Math.round(bgg * 255), Math.round(bgb * 255))
}
let blockDataPtr = sys.malloc(FRAME_SIZE)
// Streaming frame buffer rotation for temporal prediction
// Buffers rotate: NEXT -> CURRENT -> PREV each frame
let currentFieldAddr = CURR_FIELD_BUFFER // Currently being decoded
let prevFieldAddr = PREV_FIELD_BUFFER // Previous field for temporal prediction
let nextFieldAddr = NEXT_FIELD_BUFFER // Next field for temporal prediction
function rotateFieldBuffers() {
// Rotate buffers: NEXT -> CURRENT -> PREV
let temp = prevFieldAddr
prevFieldAddr = currentFieldAddr
currentFieldAddr = nextFieldAddr
nextFieldAddr = temp
}
let frameDuped = false
let currentFrameType = "I"
// Main decoding loop - simplified for performance
try {
let t1 = sys.nanoTime()
while (!stopPlay && seqread.getReadCount() < FILE_LENGTH /*&& trueFrameCount < totalFrames*/) {
// Handle interactive controls
if (interactive) {
sys.poke(-40, 1)
if (sys.peek(-41) == 67) { // Backspace
stopPlay = true
break
}
}
if (akku >= FRAME_TIME) {
// Read packet (1 byte: type)
let packetType = seqread.readOneByte()
if (packetType == 0xFF) { // Sync packet
akku -= FRAME_TIME
// Sync packet - frame complete
frameCount++
trueFrameCount++
// Swap ping-pong buffers instead of expensive memcpy (752KB copy eliminated!)
let temp = CURRENT_RGB_ADDR
CURRENT_RGB_ADDR = PREV_RGB_ADDR
PREV_RGB_ADDR = temp
} else if (packetType == TEV_PACKET_IFRAME || packetType == TEV_PACKET_PFRAME) {
// Video frame packet
let payloadLen = seqread.readInt()
let compressedPtr = seqread.readBytes(payloadLen)
updateDataRateBin(payloadLen)
// Basic sanity check on compressed data
if (payloadLen <= 0 || payloadLen > 1000000) {
serial.println(`Frame ${frameCount}: Invalid payload length: ${payloadLen}`)
sys.free(compressedPtr)
continue
}
// Decompress using gzip
// Optimized buffer size calculation for TEV YCoCg-R blocks
let actualSize
let decompressStart = sys.nanoTime()
try {
// Use gzip decompression (only compression format supported in TSVM JS)
actualSize = gzip.decompFromTo(compressedPtr, payloadLen, blockDataPtr)
decompressTime = (sys.nanoTime() - decompressStart) / 1000000.0 // Convert to milliseconds
} catch (e) {
// Decompression failed - skip this frame
decompressTime = (sys.nanoTime() - decompressStart) / 1000000.0 // Still measure time
serial.println(`Frame ${frameCount}: Gzip decompression failed, skipping (compressed size: ${payloadLen}, error: ${e})`)
sys.free(compressedPtr)
continue
}
// Hardware-accelerated TEV decoding to RGB buffers (YCoCg-R or ICtCp based on version)
try {
// duplicate every 1000th frame (pass a turn every 1000n+501st) if NTSC
if (!isNTSC || frameCount % 1000 != 501 || frameDuped) {
frameDuped = false
let decodeStart = sys.nanoTime()
let decodingHeight = isInterlaced ? (height / 2)|0 : height
if (isInterlaced) {
// For interlaced: decode current frame into currentFieldAddr
// For display: use prevFieldAddr as current, currentFieldAddr as next
graphics.tevDecode(blockDataPtr, nextFieldAddr, currentFieldAddr, width, decodingHeight, qualityY, qualityCo, qualityCg, trueFrameCount, debugMotionVectors, version, enableDeblocking, enableBoundaryAwareDecoding)
graphics.tevDeinterlace(trueFrameCount, width, decodingHeight, prevFieldAddr, currentFieldAddr, nextFieldAddr, CURRENT_RGB_ADDR, deinterlaceAlgorithm)
// Rotate field buffers for next frame: NEXT -> CURRENT -> PREV
rotateFieldBuffers()
} else {
// Progressive or first frame: normal decoding without temporal prediction
graphics.tevDecode(blockDataPtr, CURRENT_RGB_ADDR, PREV_RGB_ADDR, width, decodingHeight, qualityY, qualityCo, qualityCg, trueFrameCount, debugMotionVectors, version, enableDeblocking, enableBoundaryAwareDecoding)
}
decodeTime = (sys.nanoTime() - decodeStart) / 1000000.0 // Convert to milliseconds
// Upload RGB buffer to display framebuffer with dithering
let uploadStart = sys.nanoTime()
graphics.uploadRGBToFramebuffer(CURRENT_RGB_ADDR, width, height, frameCount, false)
uploadTime = (sys.nanoTime() - uploadStart) / 1000000.0 // Convert to milliseconds
}
else {
frameCount -= 1
frameDuped = true
serial.println(`Frame ${frameCount}: Duplicating previous frame`)
}
// Process SSF-TC subtitle events based on current playback time
if (subtitleEvents.length > 0) {
let currentTimeNs = frameCount * FRAME_TIME_NS
processSubtitleEvents(currentTimeNs)
}
// Defer audio playback until a first frame is sent
if (isInterlaced) {
// fire audio after frame 1
if (!audioFired && frameCount > 0) {
audio.play(0)
audioFired = true
}
}
else {
// fire audio after frame 0
if (!audioFired) {
audio.play(0)
audioFired = true
}
}
} catch (e) {
serial.println(`Frame ${frameCount}: Hardware ${colorSpace} decode failed: ${e}`)
}
sys.free(compressedPtr)
let biasStart = sys.nanoTime()
setBiasLighting()
biasTime = (sys.nanoTime() - biasStart) / 1000000.0 // Convert to milliseconds
// Log performance data every 60 frames (and also frame 0 for debugging)
if (frameCount % 60 == 0 || frameCount == 0) {
let totalTime = decompressTime + decodeTime + uploadTime + biasTime
serial.println(`Frame ${frameCount}: Decompress=${decompressTime.toFixed(1)}ms, Decode=${decodeTime.toFixed(1)}ms, Upload=${uploadTime.toFixed(1)}ms, Bias=${biasTime.toFixed(1)}ms, Total=${totalTime.toFixed(1)}ms`)
}
currentFrameType = packetType == TEV_PACKET_IFRAME ? "I" : "P"
} else if (packetType == TEV_PACKET_AUDIO_MP2) {
// MP2 Audio packet
let audioLen = seqread.readInt()
if (!mp2Initialised) {
mp2Initialised = true
audio.mp2Init()
}
seqread.readBytes(audioLen, SND_BASE_ADDR - 2368)
audio.mp2Decode()
audio.mp2UploadDecoded(0)
} else if (packetType == TEV_PACKET_SUBTITLE) {
// Legacy frame-locked subtitle packet (0x30)
let packetSize = seqread.readInt()
processSubtitlePacket(packetSize)
} else if (packetType == TEV_PACKET_SUBTITLE_TC) {
// SSF-TC subtitle packet (0x31) - parse and buffer for later playback
let packetSize = seqread.readInt()
parseSubtitlePacketTC(packetSize)
} else if (packetType == 0x00) {
// Silently discard, faulty subtitle creation can cause this as 0x00 is used as an argument terminator
} else {
println(`Unknown packet type: 0x${packetType.toString(16)}`)
break
}
}
let t2 = sys.nanoTime()
akku += (t2 - t1) / 1000000000.0
akku2 += (t2 - t1) / 1000000000.0
// Simple progress display
if (interactive) {
notifHideTimer += (t2 - t1)
if (!notifHidden && notifHideTimer > (NOTIF_SHOWUPTIME + FRAME_TIME)) {
// clearing function here
notifHidden = true
}
con.color_pair(253, 0)
let guiStatus = {
fps: fps,
videoRate: getVideoRate(),
frameCount: frameCount,
totalFrames: totalFrames,
frameMode: currentFrameType,
qY: qualityY,
qCo: qualityCo,
qCg: qualityCg,
akku: akku2,
fileName: fullFilePathStr,
fileOrd: 1,
resolution: `${width}x${height}${(isInterlaced) ? 'i' : ''}`,
colourSpace: colorSpace,
currentStatus: 1
}
gui.printBottomBar(guiStatus)
gui.printTopBar(guiStatus, 1)
}
t1 = t2
}
}
catch (e) {
serial.printerr(`TEV ${colorSpace} decode error: ${e}`)
errorlevel = 1
}
finally {
// Cleanup working memory (graphics memory is automatically managed)
if (blockDataPtr > 0) sys.free(blockDataPtr)
if (RGB_BUFFER_A > 0) sys.free(RGB_BUFFER_A)
if (RGB_BUFFER_B > 0) sys.free(RGB_BUFFER_B)
if (CURR_FIELD_BUFFER > 0) sys.free(CURR_FIELD_BUFFER)
if (PREV_FIELD_BUFFER > 0) sys.free(PREV_FIELD_BUFFER)
if (NEXT_FIELD_BUFFER > 0) sys.free(NEXT_FIELD_BUFFER)
audio.stop(0)
audio.purgeQueue(0)
if (interactive) {
//con.clear()
}
// set colour zero as opaque black
}
graphics.setPalette(0, 0, 0, 0, 0)
con.move(cy, cx) // restore cursor
return errorlevel