diff --git a/assets/disk0/tvdos/bin/playtav.js b/assets/disk0/tvdos/bin/playtav.js index a409e43..caad91a 100644 --- a/assets/disk0/tvdos/bin/playtav.js +++ b/assets/disk0/tvdos/bin/playtav.js @@ -37,6 +37,7 @@ const TAV_PACKET_AUDIO_ADPCM = 0x23 const TAV_PACKET_AUDIO_TAD = 0x24 const TAV_PACKET_SUBTITLE = 0x30 // Legacy SSF (frame-locked) const TAV_PACKET_SUBTITLE_TC = 0x31 // SSF-TC (timecode-based) +const TAV_PACKET_VIDEOTEX = 0x3F // Videotex (text-mode video) const TAV_PACKET_AUDIO_BUNDLED = 0x40 // Entire MP2 audio file in single packet const TAV_PACKET_EXTENDED_HDR = 0xEF const TAV_PACKET_SCREEN_MASK = 0xF2 // Screen masking (letterbox/pillarbox) @@ -1614,6 +1615,38 @@ try { let packetSize = seqread.readInt() parseSubtitlePacketTC(packetSize) } + else if (packetType === TAV_PACKET_VIDEOTEX) { + // Videotex packet (0x3F) - text-mode video + let compressedSize = seqread.readInt() + + // Read compressed data + let compressedPtr = seqread.readBytes(compressedSize) + + // Decompress with Zstd + // Allocate buffer for decompressed data (max: 2 + 80*32*3 = 7682 bytes) + let decompressedPtr = sys.malloc(8192) + let decompressedSize = gzip.decompFromTo(compressedPtr, compressedSize, decompressedPtr) + + // Read grid dimensions from first 2 bytes + let rows = sys.peek(decompressedPtr) + let cols = sys.peek(decompressedPtr + 1) + let gridSize = rows * cols + + // Calculate array offsets within decompressed data + let dataOffset = decompressedPtr + 2 + + // Copy arrays directly to graphics adapter memory + // Format: [fg-array][bg-array][char-array] + // Each array is gridSize bytes (typically 2560 for 80×32) + sys.memcpy(dataOffset, -1302529, gridSize * 3) + + // Free buffers + sys.free(compressedPtr) + sys.free(decompressedPtr) + + // Mark frame as ready + iframeReady = true + } else if (packetType === TAV_PACKET_EXTENDED_HDR) { // Extended header packet - metadata key-value pairs let numPairs = seqread.readShort() @@ -2178,6 +2211,7 @@ try { } catch (e) { serial.printerr(`TAV decode error: ${e}`) + e.printStackTrace() errorlevel = 1 } finally { diff --git a/terranmon.txt b/terranmon.txt index be39a0e..446d01f 100644 --- a/terranmon.txt +++ b/terranmon.txt @@ -219,7 +219,9 @@ Memory Space 12 bytes argument for "command" (arg1: Byte, arg2: Byte) write to this address FIRST and then write to "command" to execute the command -1134 bytes +1008 bytes + reserved +2046 bytes unused 2 bytes Cursor position in: (y*80 + x) @@ -911,8 +913,8 @@ transmission capability, and region-of-interest coding. - 6 = ICtCp monoblock perceptual - 7 = YCoCg-R multi-tile perceptual - 8 = ICtCp multi-tile perceptual - uint16 Width: picture width in pixels - uint16 Height: picture height in pixels + uint16 Width: picture width in pixels. Columns count for Videotex-only file. + uint16 Height: picture height in pixels. Rows count for Videotex-only file. uint8 FPS: frames per second. Use 0x00 for still pictures uint32 Total Frames: number of video frames - use 0 to denote not-finalised video stream @@ -923,7 +925,7 @@ transmission capability, and region-of-interest coding. - 2 = CDF 13/7 (experimental) - 16 = DD-4 (Four-point interpolating Deslauriers-Dubuc; experimental) - 255 = Haar (demonstration purpose only) - uint8 Decomposition Levels: number of DWT levels (1-6+) + uint8 Decomposition Levels: number of DWT levels (1-6+; use 0 if it has no video or Videotex only) uint8 Quantiser Index for Y channel (uses exponential numeric system; 0: lossless, 255: potato) uint8 Quantiser Index for Co channel (uses exponential numeric system; 0: lossless, 255: potato) uint8 Quantiser Index for Cg channel (uses exponential numeric system; 0: lossless, 255: potato) @@ -938,6 +940,8 @@ transmission capability, and region-of-interest coding. - bit 2 = is lossless mode (shorthand for `-q 6 -Q0,0,0 -w 0 --intra-only --no-perceptual-tuning --arate 384`) - bit 3 = has region-of-interest coding (for still pictures only) + - bit 4 = reserved (crop encoding?) + - bit 7 = has no video uint8 Encoder quality level (stored with bias of 1 (q0=1); used to derive anisotropy value) uint8 Channel layout (bit-field: bit 0=has alpha, bit 1=has chroma inverted, bit 2=has luma inverted) * Luma-only videos must be decoded with fixed Chroma=0 @@ -954,7 +958,13 @@ transmission capability, and region-of-interest coding. - 0 = Twobit-plane significance map (deprecated) - 1 = Embedded Zero Block Coding - 2 = Raw coefficients (debugging purpose only) - uint8 Reserved[2]: fill with zeros + uint8 Encoder Preset + - Bit 0 = use finer motion (finer temporal quantisation) + - Bit 1 = reduce grain synthesis + Preset "Default" -> 0x00 + Preset "Sports" -> 0x01 + Preset "Anime" -> 0x02 + uint8 Reserved[1]: fill with zeros uint8 Device Orientation - 0 = No rotation - 1 = Clockwise 90 deg @@ -992,6 +1002,7 @@ transmission capability, and region-of-interest coding. 0x31: Subtitle in "Simple" format with timecodes 0x32: Subtitle in "Karaoke" format 0x33: Subtitle in "Karaoke" format with timecodes + 0x3F: Videotex (full-frame text buffer image) 0x40: MP2 audio track (32 KHz) 0x41: Zstd-compressed 8-bit PCM (32 KHz, audio hardware's native format) @@ -1128,6 +1139,18 @@ transmission capability, and region-of-interest coding. uint32 Compressed Size * Zstd-compressed TAD +## Videotex Packet Structure + uint8 Packet Type (0x3F) + uint32 Compressed Size + * Zstd-compressed payload, where: + uint8 Rows + uint8 Columns + * Foreground colours + * Background colours + * Characters + + + ## GOP Unified Packet Structure (0x12) Implemented on 2025-10-15 for temporal 3D DWT with unified preprocessing. diff --git a/tsvm_core/src/net/torvald/tsvm/VM.kt b/tsvm_core/src/net/torvald/tsvm/VM.kt index 88b7fd9..8f1f0ef 100644 --- a/tsvm_core/src/net/torvald/tsvm/VM.kt +++ b/tsvm_core/src/net/torvald/tsvm/VM.kt @@ -667,8 +667,8 @@ class VM( val fromDev = getDev(from, len, false) val toDev = getDev(to, len, true) -// println("from = $from, to = $to") -// println("fromDev = $fromDev, toDev = $toDev") +// System.err.println("[VM.memcpy] from = $from, to = $to") +// System.err.println("[VM.memcpy] fromDev = $fromDev, toDev = $toDev") if (fromDev != null && toDev != null) UnsafeHelper.memcpy(fromDev, toDev, len) diff --git a/tsvm_core/src/net/torvald/tsvm/VMJSR223Delegate.kt b/tsvm_core/src/net/torvald/tsvm/VMJSR223Delegate.kt index 6360c42..243774c 100644 --- a/tsvm_core/src/net/torvald/tsvm/VMJSR223Delegate.kt +++ b/tsvm_core/src/net/torvald/tsvm/VMJSR223Delegate.kt @@ -4,6 +4,7 @@ import net.torvald.UnsafeHelper import net.torvald.terrarum.modulecomputers.virtualcomputer.tvd.toUint import net.torvald.terrarum.modulecomputers.virtualcomputer.tvd.toUlong import net.torvald.tsvm.peripheral.* +import kotlin.math.absoluteValue /** * Pass the instance of the class to the ScriptEngine's binding, preferably under the namespace of "vm" @@ -14,14 +15,22 @@ class VMJSR223Delegate(private val vm: VM) { (from in start..end && (from + len) in start..end) private fun getDev(from: Long, len: Long, isDest: Boolean): Long? { - return if (from >= 0) vm.usermem.ptr + from +// System.err.print("getDev(from=$from, len=$len, isDest=$isDest) -> ") + + return if (from >= 0) { +// System.err.println("USERMEM offset=$from") + + vm.usermem.ptr + from + } // MMIO area else if (from in -1048576..-1 && (from - len) in -1048577..-1) { - val fromIndex = (-from-1) / 131072 + val fromIndex = ((-from-1) / 131072).absoluteValue val dev = vm.peripheralTable[fromIndex.toInt()].peripheral ?: return null val fromRel = (-from-1) % 131072 if (fromRel + len > 131072) return null +// System.err.println("MMIO dev=${dev.typestring}, fromIndex=$fromIndex, fromRel=$fromRel") + return if (dev is IOSpace) { if (relPtrInDev(fromRel, len, 1024, 2047)) dev.peripheralFast.ptr + fromRel - 1024 else if (relPtrInDev(fromRel, len, 4096, 8191)) (if (isDest) dev.blockTransferTx[0] else dev.blockTransferRx[0]).ptr + fromRel - 4096 @@ -50,6 +59,8 @@ class VMJSR223Delegate(private val vm: VM) { val fromRel = (-from-1) % 1048576 if (fromRel + len > 1048576) return null +// System.err.println("MEMORY dev=${dev.typestring}, fromIndex=$fromIndex, fromRel=$fromRel") + return if (dev is AudioAdapter) { if (relPtrInDev(fromRel, len, 0, 114687)) dev.sampleBin.ptr + fromRel - 0 else null @@ -111,8 +122,8 @@ class VMJSR223Delegate(private val vm: VM) { val fromDev = getDev(from, len, false) val toDev = getDev(to, len, true) -// println("from = $from, to = $to") -// println("fromDev = $fromDev, toDev = $toDev") +// System.err.println("[sys.memcpy] from = $from, to = $to") +// System.err.println("[sys.memcpy] fromDev = $fromDev, toDev = $toDev") if (fromDev != null && toDev != null) UnsafeHelper.memcpy(fromDev, toDev, len) diff --git a/video_encoder/encoder_tav.c b/video_encoder/encoder_tav.c index 55c11da..30fc0af 100644 --- a/video_encoder/encoder_tav.c +++ b/video_encoder/encoder_tav.c @@ -1826,7 +1826,6 @@ typedef struct tav_encoder_s { int separate_audio_track; // 1 = write entire MP2 file as packet 0x40 after header, 0 = interleave audio (default) int pcm8_audio; // 1 = use 8-bit PCM audio (packet 0x21), 0 = use MP2 (default) int tad_audio; // 1 = use TAD audio (packet 0x24), 0 = use MP2/PCM8 (default, quality follows quality_level) - int enable_letterbox_detect; // 1 = detect and emit letterbox/pillarbox packets (default), 0 = disable int enable_crop_encoding; // 1 = encode cropped active region only (Phase 2), 0 = encode full frame (default) // Active region tracking (for Phase 2 crop encoding) @@ -2454,7 +2453,6 @@ static tav_encoder_t* create_encoder(void) { enc->separate_audio_track = 0; // Default: interleave audio packets enc->pcm8_audio = 0; // Default: use MP2 audio enc->tad_audio = 0; // Default: use MP2 audio (TAD quality follows quality_level) - enc->enable_letterbox_detect = 1; // Default: enable letterbox/pillarbox detection enc->enable_crop_encoding = 0; // Default: disabled (Phase 2 experimental) // Active region tracking (initialized to full frame, updated when crop encoding enabled) @@ -8736,7 +8734,7 @@ static void normalize_dimension_clusters(uint16_t *values, int count) { // Write all screen masking packets before first frame (similar to SSF-TC subtitles) // Uses median filtering + clustering to normalize geometry to predominant aspect ratios static void write_all_screen_mask_packets(tav_encoder_t *enc, FILE *output) { - if (!enc->enable_letterbox_detect || !enc->two_pass_mode) { + if (!enc->enable_crop_encoding || !enc->two_pass_mode) { return; // Letterbox detection requires two-pass mode } @@ -10412,7 +10410,7 @@ static int two_pass_first_pass(tav_encoder_t *enc, const char *input_file) { } // Detect letterbox/pillarbox if enabled - if (enc->enable_letterbox_detect) { + if (enc->enable_crop_encoding) { // Set current_frame_rgb temporarily for detection uint8_t *saved_current = enc->current_frame_rgb; enc->current_frame_rgb = frame_rgb; @@ -10666,7 +10664,6 @@ int main(int argc, char *argv[]) { {"tad-audio", no_argument, 0, 1028}, {"raw-coeffs", no_argument, 0, 1029}, {"single-pass", no_argument, 0, 1050}, // disable two-pass encoding with wavelet-based scene detection - {"no-letterbox-detect", no_argument, 0, 1051}, // disable letterbox/pillarbox detection {"enable-crop-encoding", no_argument, 0, 1052}, // Phase 2: encode cropped active region only (experimental) {"help", no_argument, 0, '?'}, {0, 0, 0, 0} @@ -10898,10 +10895,6 @@ int main(int argc, char *argv[]) { enc->two_pass_mode = 0; printf("Two-pass wavelet-based scene change detection disabled\n"); break; - case 1051: // --no-letterbox-detect - enc->enable_letterbox_detect = 0; - printf("Letterbox/pillarbox detection disabled\n"); - break; case 1052: // --enable-crop-encoding enc->enable_crop_encoding = 1; printf("Phase 2 crop encoding enabled (experimental)\n"); @@ -11380,7 +11373,7 @@ int main(int argc, char *argv[]) { enc->encoding_width = enc->width; enc->encoding_height = enc->height; - if (enc->enable_crop_encoding && enc->enable_letterbox_detect && enc->two_pass_mode) { + if (enc->enable_crop_encoding && enc->two_pass_mode) { // Phase 2: Use GOP-level dimensions for temporal DWT (3D-DWT mode) // This ensures all frames in a GOP have the same encoding dimensions // IMPORTANT: Always use GOP-level dimensions in temporal DWT mode, even if there's no cropping benefit, diff --git a/video_encoder/encoder_tav_text.c b/video_encoder/encoder_tav_text.c new file mode 100644 index 0000000..6ed76f3 --- /dev/null +++ b/video_encoder/encoder_tav_text.c @@ -0,0 +1,662 @@ +/* +encoder_tav_text.c +Text-based video encoder for TSVM using custom font ROMs + +Outputs Videotex files with custom header and packet type 0x3F (text mode) + +File structure: + - Videotex header (32 bytes): magic "\x1FTSVM-VT", version, grid dims, fps, total_frames + - Extended header packet (0xEF): BGNT, ENDT, CDAT, VNDR, FMPG + - Font ROM packets (0x30): lowrom and highrom (1920 bytes each) + - Per-frame sequence: [audio 0x20], [timecode 0xFD], [videotex 0x3F], [sync 0xFF] + +Videotex packet structure (0x3F): Zstd([rows][cols][fg-array][bg-array][char-array]) + - rows: uint8 (32) + - cols: uint8 (80) + - fg-array: rows*cols bytes (foreground colors, 0xF0=black, 0xFE=white) + - bg-array: rows*cols bytes (background colors, 0xF0=black, 0xFE=white) + - char-array: rows*cols bytes (glyph indices 0-255) + +Total uncompressed size: 2 + (80*32*3) = 7682 bytes +Separated arrays compress much better (fg/bg are just 0xF0/0xFE runs) +Video size: 80×32 characters (560×448 pixels with 7×14 font) +Audio: MP2 encoding at 96 kbps, 32 KHz stereo (packet 0x20) +Each text frame is treated as an I-frame with sync packet + +Usage: + gcc -O3 -std=c11 -Wall encoder_tav_text.c -o encoder_tav_text -lm -lzstd + ./encoder_tav_text -i video.mp4 -f font.chr -o output.vtx +*/ + +#define _POSIX_C_SOURCE 200809L +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define ENCODER_VENDOR_STRING "Encoder-TAV-Text 20251121 (videotex)" + +#define CHAR_W 7 +#define CHAR_H 14 +#define GRID_W 80 +#define GRID_H 32 +#define PIXEL_W (GRID_W * CHAR_W) // 560 +#define PIXEL_H (GRID_H * CHAR_H) // 448 +#define PATCH_SZ (CHAR_W * CHAR_H) +#define SAMPLE_RATE 32000 + +// TAV packet types +#define PACKET_TIMECODE 0xFD +#define PACKET_SYNC 0xFF +#define PACKET_AUDIO_MP2 0x20 +#define PACKET_SSF 0x30 +#define PACKET_TEXT 0x3F +#define PACKET_EXTENDED_HDR 0xEF + +// SSF opcodes for font ROM +#define SSF_OPCODE_LOWROM 0x80 +#define SSF_OPCODE_HIGHROM 0x81 + +// Font ROM size constants +#define FONTROM_PADDED_SIZE 1920 +#define GLYPHS_PER_ROM 128 + +// Color mapping (4-bit RGB to TSVM palette) +#define COLOR_BLACK 0xF0 +#define COLOR_WHITE 0xFE + +typedef struct { + uint8_t *data; // Binary glyph data (PATCH_SZ bytes per glyph) + int count; // Number of glyphs +} FontROM; + +// Get FFmpeg version string +char *get_ffmpeg_version(void) { + FILE *pipe = popen("ffmpeg -version 2>&1 | head -1", "r"); + if (!pipe) return NULL; + + char *version = malloc(256); + if (!version) { + pclose(pipe); + return NULL; + } + + if (fgets(version, 256, pipe)) { + // Remove trailing newline + size_t len = strlen(version); + if (len > 0 && version[len - 1] == '\n') { + version[len - 1] = '\0'; + } + pclose(pipe); + return version; + } + + free(version); + pclose(pipe); + return NULL; +} + +// Detect video FPS using ffprobe +float detect_fps(const char *video_path) { + char cmd[1024]; + snprintf(cmd, sizeof(cmd), + "ffprobe -v error -select_streams v:0 -show_entries stream=r_frame_rate " + "-of default=noprint_wrappers=1:nokey=1 \"%s\" 2>/dev/null", + video_path); + + FILE *pipe = popen(cmd, "r"); + if (!pipe) return 30.0f; // fallback + + char fps_str[64] = {0}; + if (fgets(fps_str, sizeof(fps_str), pipe)) { + // Parse fraction like "30/1" or "24000/1001" + int num = 0, den = 1; + if (sscanf(fps_str, "%d/%d", &num, &den) == 2 && den > 0) { + pclose(pipe); + return (float)num / (float)den; + } + } + pclose(pipe); + return 30.0f; // fallback +} + +// Load font ROM (14 bytes per glyph, no header) +FontROM *load_font_rom(const char *path) { + FILE *f = fopen(path, "rb"); + if (!f) return NULL; + + fseek(f, 0, SEEK_END); + long size = ftell(f); + fseek(f, 0, SEEK_SET); + + if (size % 14 != 0) { + fprintf(stderr, "Warning: ROM size not divisible by 14 (got %ld bytes)\n", size); + } + + int glyph_count = size / 14; + FontROM *rom = malloc(sizeof(FontROM)); + rom->count = glyph_count; + rom->data = malloc(glyph_count * PATCH_SZ); + + // Read and unpack glyphs + for (int g = 0; g < glyph_count; g++) { + uint8_t row_bytes[14]; + if (fread(row_bytes, 14, 1, f) != 1) { + free(rom->data); + free(rom); + fclose(f); + return NULL; + } + + // Unpack bits to binary pixels + for (int row = 0; row < CHAR_H; row++) { + for (int col = 0; col < CHAR_W; col++) { + // Bit 6 = leftmost, bit 0 = rightmost + int bit = (row_bytes[row] >> (6 - col)) & 1; + rom->data[g * PATCH_SZ + row * CHAR_W + col] = bit; + } + } + } + + fclose(f); + fprintf(stderr, "Loaded font ROM: %d glyphs\n", glyph_count); + return rom; +} + +// Find best matching glyph for a grayscale patch +int find_best_glyph(const uint8_t *patch, const FontROM *rom, uint8_t *out_bg, uint8_t *out_fg) { + // Try both normal and inverted matching + int best_glyph = 0; + float best_error = INFINITY; + uint8_t best_bg = COLOR_BLACK, best_fg = COLOR_WHITE; + + for (int g = 0; g < rom->count; g++) { + const uint8_t *glyph = &rom->data[g * PATCH_SZ]; + + // Try normal: glyph 1 = fg, glyph 0 = bg + float err_normal = 0; + for (int i = 0; i < PATCH_SZ; i++) { + int expected = glyph[i] ? 255 : 0; + int diff = patch[i] - expected; + err_normal += diff * diff; + } + + // Try inverted: glyph 0 = fg, glyph 1 = bg + float err_inverted = 0; + for (int i = 0; i < PATCH_SZ; i++) { + int expected = glyph[i] ? 0 : 255; + int diff = patch[i] - expected; + err_inverted += diff * diff; + } + + if (err_normal < best_error) { + best_error = err_normal; + best_glyph = g; + best_bg = COLOR_BLACK; + best_fg = COLOR_WHITE; + } + if (err_inverted < best_error) { + best_error = err_inverted; + best_glyph = g; + best_bg = COLOR_WHITE; + best_fg = COLOR_BLACK; + } + } + + *out_bg = best_bg; + *out_fg = best_fg; + return best_glyph; +} + +// Convert frame to text mode +void frame_to_text(const uint8_t *pixels, const FontROM *rom, + uint8_t *bg_col, uint8_t *fg_col, uint8_t *chars) { + uint8_t patch[PATCH_SZ]; + + for (int gr = 0; gr < GRID_H; gr++) { + for (int gc = 0; gc < GRID_W; gc++) { + int idx = gr * GRID_W + gc; + + // Extract patch + for (int y = 0; y < CHAR_H; y++) { + for (int x = 0; x < CHAR_W; x++) { + int px = gc * CHAR_W + x; + int py = gr * CHAR_H + y; + patch[y * CHAR_W + x] = pixels[py * PIXEL_W + px]; + } + } + + // Find best match + chars[idx] = find_best_glyph(patch, rom, &bg_col[idx], &fg_col[idx]); + } + } +} + +// Get current time in nanoseconds since UNIX epoch +uint64_t get_current_time_ns(void) { + struct timeval tv; + gettimeofday(&tv, NULL); + return (uint64_t)tv.tv_sec * 1000000000ULL + (uint64_t)tv.tv_usec * 1000ULL; +} + +// Write Videotex header (32 bytes, similar to TAV but simpler) +void write_videotex_header(FILE *f, uint8_t fps, uint32_t total_frames) { + fwrite("\x1FTSVMTAV", 8, 1, f); + + // Version: 1 (uint8) + fputc(1, f); + + // Grid dimensions (uint8 each) + fputc(GRID_W, f); // cols = 80 + fputc(0, f); + fputc(GRID_H, f); // rows = 32 + fputc(0, f); + + // FPS (uint8) + fputc(fps, f); + + // Total frames (uint32, little-endian) + fwrite(&total_frames, sizeof(uint32_t), 1, f); + + fputc(0, f); // wavelet filter type + fputc(0, f); // decomposition levels + fputc(0, f); // quantiser Y + fputc(0, f); // quantiser Co + fputc(0, f); // quantiser Cg + + // Feature Flags + fputc(0x03, f); // bit 0 = has audio; bit 1 = has subtitle (Videotex is classified as subtitles) + + // Video Flags + fputc(0x80, f); // bit 7 = has no video (Videotex is classified as subtitles) + + + fputc(0, f); // encoder quality level + fputc(0x02, f); // channel layout: Y only + fputc(0, f); // entropy coder + + fputc(0, f); // reserved + fputc(0, f); // reserved + + fputc(0, f); // device orientation: no rotation + fputc(0, f); // file role: generic +} + +// Write extended header packet with metadata +// Returns the file offset where ENDT value is written (for later update) +long write_extended_header(FILE *f, uint64_t creation_time_ns, const char *ffmpeg_version) { + fputc(PACKET_EXTENDED_HDR, f); + + // Helper macros for key-value pairs + #define WRITE_KV_UINT64(key_str, value) do { \ + fwrite(key_str, 1, 4, f); \ + uint8_t value_type = 0x04; /* Uint64 */ \ + fwrite(&value_type, 1, 1, f); \ + uint64_t val = (value); \ + fwrite(&val, sizeof(uint64_t), 1, f); \ + } while(0) + + #define WRITE_KV_BYTES(key_str, data, len) do { \ + fwrite(key_str, 1, 4, f); \ + uint8_t value_type = 0x10; /* Bytes */ \ + fwrite(&value_type, 1, 1, f); \ + uint16_t length = (len); \ + fwrite(&length, sizeof(uint16_t), 1, f); \ + fwrite((data), 1, (len), f); \ + } while(0) + + // Count key-value pairs (BGNT, ENDT, CDAT, VNDR, FMPG) + uint16_t num_pairs = ffmpeg_version ? 5 : 4; // FMPG is optional + fwrite(&num_pairs, sizeof(uint16_t), 1, f); + + // BGNT: Video begin time (0 for frame 0) + WRITE_KV_UINT64("BGNT", 0ULL); + + // ENDT: Video end time (placeholder, will be updated at end) + long endt_offset = ftell(f); + WRITE_KV_UINT64("ENDT", 0ULL); + + // CDAT: Creation time in nanoseconds since UNIX epoch + WRITE_KV_UINT64("CDAT", creation_time_ns); + + // VNDR: Encoder name and version + const char *vendor_str = ENCODER_VENDOR_STRING; + WRITE_KV_BYTES("VNDR", vendor_str, strlen(vendor_str)); + + // FMPG: FFmpeg version (if available) + if (ffmpeg_version) { + WRITE_KV_BYTES("FMPG", ffmpeg_version, strlen(ffmpeg_version)); + } + + #undef WRITE_KV_UINT64 + #undef WRITE_KV_BYTES + + // Return offset of ENDT value (skip key, type byte) + return endt_offset + 4 + 1; // 4 bytes for "ENDT", 1 byte for type +} + +// Write font ROM packet (SSF packet type 0x30) +void write_fontrom_packet(FILE *f, const uint8_t *rom_data, size_t data_size, uint8_t opcode) { + // Prepare padded ROM data (pad to FONTROM_PADDED_SIZE with zeros) + uint8_t *padded_data = calloc(1, FONTROM_PADDED_SIZE); + memcpy(padded_data, rom_data, data_size); + + // Packet structure: + // [type:0x30][size:uint32][index:uint24][opcode:uint8][length:uint16][data][terminator:0x00] + uint32_t packet_size = 3 + 1 + 2 + FONTROM_PADDED_SIZE + 1; + + // Write packet type and size + fputc(PACKET_SSF, f); + fwrite(&packet_size, sizeof(uint32_t), 1, f); + + // Write SSF payload + // Index (3 bytes, always 0 for font ROM) + fputc(0, f); + fputc(0, f); + fputc(0, f); + + // Opcode (0x80=lowrom, 0x81=highrom) + fputc(opcode, f); + + // Payload length (uint16, little-endian) + uint16_t payload_len = FONTROM_PADDED_SIZE; + fwrite(&payload_len, sizeof(uint16_t), 1, f); + + // Font data (padded to 1920 bytes) + fwrite(padded_data, 1, FONTROM_PADDED_SIZE, f); + + // Terminator + fputc(0x00, f); + + free(padded_data); + + fprintf(stderr, "Font ROM uploaded: %zu bytes (padded to %d), opcode 0x%02X\n", + data_size, FONTROM_PADDED_SIZE, opcode); +} + +// Write timecode packet (nanoseconds) +void write_timecode(FILE *f, uint64_t timecode_ns) { + fputc(PACKET_TIMECODE, f); + fwrite(&timecode_ns, sizeof(uint64_t), 1, f); +} + +// Write sync packet +void write_sync(FILE *f) { + fputc(PACKET_SYNC, f); +} + +// Write MP2 audio packet +void write_audio_mp2(FILE *f, const uint8_t *data, uint32_t size) { + fputc(PACKET_AUDIO_MP2, f); + fwrite(&size, sizeof(uint32_t), 1, f); + fwrite(data, 1, size, f); +} + +// Write text packet with separated arrays (better compression) +void write_text_packet(FILE *f, const uint8_t *bg_col, const uint8_t *fg_col, + const uint8_t *chars, int rows, int cols) { + int grid_size = rows * cols; + + // Prepare uncompressed data: [rows][cols][fg-array][bg-array][char-array] + // Separated arrays compress much better (fg/bg are just 0xF0/0xFE runs) + size_t uncompressed_size = 2 + grid_size * 3; + uint8_t *uncompressed = malloc(uncompressed_size); + + uncompressed[0] = rows; + uncompressed[1] = cols; + + // Copy arrays in order: foreground, background, characters + memcpy(&uncompressed[2], fg_col, grid_size); // Foreground first + memcpy(&uncompressed[2 + grid_size], bg_col, grid_size); // Background second + memcpy(&uncompressed[2 + grid_size * 2], chars, grid_size); // Characters third + + // Compress with Zstd + size_t max_compressed = ZSTD_compressBound(uncompressed_size); + uint8_t *compressed = malloc(max_compressed); + size_t compressed_size = ZSTD_compress(compressed, max_compressed, + uncompressed, uncompressed_size, 3); + + if (ZSTD_isError(compressed_size)) { + fprintf(stderr, "Zstd compression error\n"); + exit(1); + } + + // Write packet: [type][size][data] + fputc(PACKET_TEXT, f); + uint32_t size32 = compressed_size; + fwrite(&size32, 4, 1, f); + fwrite(compressed, compressed_size, 1, f); + + free(compressed); + free(uncompressed); +} + +int main(int argc, char **argv) { + if (argc < 7) { + fprintf(stderr, "Usage: %s -i