diff --git a/doc/api/rest_api_openapi_u64.yaml b/doc/api/rest_api_openapi_u64.yaml index d1484f8da..85818528a 100644 --- a/doc/api/rest_api_openapi_u64.yaml +++ b/doc/api/rest_api_openapi_u64.yaml @@ -3375,6 +3375,13 @@ paths: schema: type: string example: '192.168.1.10:11000' + - name: palette + in: query + required: false + description: 'For video, request runtime VIC palette packets (0 or 1). The setting is device-wide: it applies to every receiver of the stream, and the most recent start sets it.' + schema: + type: integer + example: 0 responses: '200': description: The stream is running. @@ -3382,6 +3389,17 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + Palette must be 0 or 1 and is only valid for the video stream: + value: + errors: + - Palette must be 0 or 1 and is only valid for the video stream '403': $ref: '#/components/responses/Forbidden' '404': diff --git a/software/api/route_streams.cc b/software/api/route_streams.cc index 0dfa580dd..78015645b 100644 --- a/software/api/route_streams.cc +++ b/software/api/route_streams.cc @@ -31,11 +31,13 @@ API_DOC(PUT, streams, start, PATH_PARAM("stream", "string", "Which stream to act on.", "video") PATH_PARAM_ENUM("stream", "video,audio,debug") PARAM("ip", "string", "Where to send the stream. An address, optionally followed by a port.", "", "192.168.1.10:11000") + PARAM("palette", "integer", "For video, request runtime VIC palette packets (0 or 1). The setting is device-wide: it applies to every receiver of the stream, and the most recent start sets it.", "", "0") RESPONSE("200", "application/json", "ErrorResponse", "The stream is running.", "") + RESPONSE_ERROR("400", "Palette must be 0 or 1 and is only valid for the video stream", "") RESPONSE_ERROR("404", "Unrecognized stream name 'screen'", "") RESPONSE_ERROR("500", "No Operational Network Interface", "") ) -API_CALL(PUT, streams, start, NULL, ARRAY ( { { "ip", P_REQUIRED } })) +API_CALL(PUT, streams, start, NULL, ARRAY ( { { "ip", P_REQUIRED }, { "palette", P_OPTIONAL } })) { const char *streamName = args.get_path(0); SubsysCommand *sys_command; @@ -52,13 +54,23 @@ API_CALL(PUT, streams, start, NULL, ARRAY ( { { "ip", P_REQUIRED } })) return; } + // Checked before anything is stopped, so a rejected request changes nothing. + const char *paletteArg = args.get_or("palette", NULL); + const bool paletteRequested = paletteArg && strcmp(paletteArg, "1") == 0; + if (paletteArg && (streamIndex != 0 || (strcmp(paletteArg, "0") != 0 && !paletteRequested))) { + resp->error("Palette must be 0 or 1 and is only valid for the video stream"); + resp->json_response(HTTP_BAD_REQUEST); + return; + } + if (streamIndex == 0) { // video streams require debug to be off sys_command = new SubsysCommand(NULL, -1, (int)dataStreamer, 2, "", ""); sys_command->direct_call = DataStreamer :: S_stopStream; sys_command->execute(); } - sys_command = new SubsysCommand(NULL, -1, (int)dataStreamer, streamIndex, args["ip"], ""); + const int mode = streamIndex | (paletteRequested ? STREAM_MODE_PALETTE : 0); + sys_command = new SubsysCommand(NULL, -1, (int)dataStreamer, mode, args["ip"], ""); sys_command->direct_call = DataStreamer :: S_startStream; SubsysResultCode_t retval = sys_command->execute(); resp->error(SubsysCommand::error_string(retval.status)); diff --git a/software/io/network/data_streamer.cc b/software/io/network/data_streamer.cc index 19fce6c6c..de0f2f7de 100644 --- a/software/io/network/data_streamer.cc +++ b/software/io/network/data_streamer.cc @@ -8,6 +8,7 @@ #include "network_interface.h" #include "socket.h" #include "netdb.h" +#include "u64_config.h" #include "userinterface.h" #include "profiler.h" #include "init_function.h" @@ -38,6 +39,11 @@ struct t_cfg_definition stream_cfg[] = { DataStreamer :: DataStreamer() { my_ip = 0; + palette_stream_requested = false; + palette_task_handle = NULL; + palette_socket = -1; + palette_socket_dest_ip = 0; + palette_socket_dest_port = 0; memset(streams, 0, 4*sizeof(stream_config_t)); cfg = ConfigManager :: getConfigManager()->register_store(0x44617461, "Data Streams", stream_cfg, NULL); @@ -47,12 +53,17 @@ DataStreamer :: DataStreamer() for (int i=0; i < 4; i++) { timers[i] = xTimerCreate("StreamTimer", 100, pdFALSE, (void *)i, DataStreamer :: S_timer); } + if (xTaskCreate(DataStreamer :: S_palette_task, "VIC Palette", configMINIMAL_STACK_SIZE, + this, PRIO_NETSERVICE, &palette_task_handle) != pdPASS) { + palette_task_handle = NULL; + puts("Could not create VIC palette stream task"); + } } // This should never be called DataStreamer :: ~DataStreamer() { - + closePaletteSocket(); } DataStreamer *dataStreamer; @@ -82,10 +93,19 @@ void DataStreamer :: S_timer(TimerHandle_t a) if ((streamID >= 0) && (streamID <= 3)) { stream_config_t *stream = &(dataStreamer->streams[streamID]); stream->enable = 0; + if (streamID == 0) { + dataStreamer->palette_stream_requested = false; + dataStreamer->wakePaletteTask(); + } dataStreamer->calculate_udp_headers(streamID); } } +void DataStreamer :: S_palette_task(void *context) +{ + ((DataStreamer *)context)->paletteTask(); +} + SubsysResultCode_e DataStreamer :: startStream(SubsysCommand *cmd) { if(NetworkInterface :: getNumberOfInterfaces() < 1) { @@ -98,12 +118,16 @@ SubsysResultCode_e DataStreamer :: startStream(SubsysCommand *cmd) return SSRET_NO_NETWORK; // shouldn't happen } - int streamID = cmd->mode; + int streamID = cmd->mode & 0xFF; if ((streamID < 0) || (streamID > 3)) { if (cmd->user_interface) cmd->user_interface->popup("Invalid Stream ID", BUTTON_OK); return SSRET_INVALID_PARAMETER; } + const bool palette = (streamID == 0) && (cmd->mode & STREAM_MODE_PALETTE); stream_config_t *stream = &streams[streamID]; + // Resolve into a copy. Until this start succeeds, the running stream and + // the palette task keep the destination they have. + stream_config_t next = *stream; union { uint32_t ipaddr32[3]; @@ -112,11 +136,12 @@ SubsysResultCode_e DataStreamer :: startStream(SubsysCommand *cmd) uint8_t his_mac[6]; memset(his_mac, 0, 6); + uint8_t source_mac[6]; intf->getIpAddr(ip.ipaddr); - intf->getMacAddr(my_mac); - my_ip = ip.ipaddr32[0]; + intf->getMacAddr(source_mac); + const uint32_t source_ip = ip.ipaddr32[0]; - if (!(intf->is_link_up()) || (my_ip == 0)) { + if (!(intf->is_link_up()) || (source_ip == 0)) { if (cmd->user_interface) cmd->user_interface->popup("No (valid) link", BUTTON_OK); return SSRET_NO_NETWORK; } @@ -170,11 +195,11 @@ SubsysResultCode_e DataStreamer :: startStream(SubsysCommand *cmd) } uint32_t *addrs = (uint32_t *)*(ret_host->h_addr_list); - stream->dest_ip = addrs[0]; + next.dest_ip = addrs[0]; - uint32_t query_ip = stream->dest_ip; - if ((stream->dest_ip & ip.ipaddr32[1]) != (ip.ipaddr32[2] & ip.ipaddr32[1])) { - printf("You requested an external IP address (%08X)\n", stream->dest_ip); + uint32_t query_ip = next.dest_ip; + if ((next.dest_ip & ip.ipaddr32[1]) != (ip.ipaddr32[2] & ip.ipaddr32[1])) { + printf("You requested an external IP address (%08X)\n", next.dest_ip); query_ip = ip.ipaddr32[2]; } @@ -184,39 +209,52 @@ SubsysResultCode_e DataStreamer :: startStream(SubsysCommand *cmd) vic_dest_ip = vic_dest.addr; */ - stream->dest_port = 11000 + streamID; + next.dest_port = 11000 + streamID; if (behind_colon) { - sscanf(behind_colon, "%d", &stream->dest_port); + sscanf(behind_colon, "%d", &next.dest_port); } - if (!stream->dest_port) { + if (!next.dest_port) { if (cmd->user_interface) cmd->user_interface->popup("Destination Port cannot be 0.", BUTTON_OK); return SSRET_INVALID_PARAMETER; } // destination mac - if ((stream->dest_ip & 0x000000F8) == 0x000000E8) { + if ((next.dest_ip & 0x000000F0) == 0x000000E0) { printf("** User requested Multicast stream\n"); - } else if (stream->dest_ip == 0xFFFFFFFF) { + } else if (next.dest_ip == 0xFFFFFFFF) { printf("** User requested Broadcast stream\n"); } else { bool ok = false; for(int i=0;i<10;i++) { - send_udp_packet(query_ip, stream->dest_port); + send_udp_packet(query_ip, next.dest_port); vTaskDelay(20); - if (intf->peekArpTable(query_ip, stream->dest_mac)) { + if (intf->peekArpTable(query_ip, next.dest_mac)) { ok = true; break; } } if (ok) { - printf("** Your MAC address is %b:%b:%b:%b:%b:%b. Got ya!\n", stream->dest_mac[0], stream->dest_mac[1], stream->dest_mac[2], - stream->dest_mac[3], stream->dest_mac[4], stream->dest_mac[5]); + printf("** Your MAC address is %b:%b:%b:%b:%b:%b. Got ya!\n", next.dest_mac[0], next.dest_mac[1], next.dest_mac[2], + next.dest_mac[3], next.dest_mac[4], next.dest_mac[5]); } else { if (cmd->user_interface) cmd->user_interface->popup("Cannot find MAC of specified host.", BUTTON_OK); return SSRET_NETWORK_RESOLVE_ERROR; } } - stream->enable = 1; + // All of it resolved, so commit it in one step: the palette task reads + // the destination, the enable and the opt-in together. + next.enable = 1; + taskENTER_CRITICAL(); + my_ip = source_ip; + memcpy(my_mac, source_mac, 6); + *stream = next; + if (streamID == 0) { + palette_stream_requested = palette; + } + taskEXIT_CRITICAL(); + if (streamID == 0) { + wakePaletteTask(); + } // start stream! calculate_udp_headers(streamID); @@ -245,6 +283,10 @@ SubsysResultCode_e DataStreamer :: stopStream(SubsysCommand *cmd) } stream_config_t *stream = &streams[streamID]; stream->enable = 0; + if (streamID == 0) { + palette_stream_requested = false; + wakePaletteTask(); + } calculate_udp_headers(streamID); return SSRET_OK; } @@ -282,7 +324,7 @@ void DataStreamer :: update_task_items(bool writablePath) void DataStreamer :: send_udp_packet(uint32_t ip, uint16_t port) { int sockfd; - static struct sockaddr_in server; + struct sockaddr_in server; sockfd = socket(AF_INET, SOCK_DGRAM, 0); if (sockfd < 0) @@ -305,6 +347,142 @@ void DataStreamer :: send_udp_packet(uint32_t ip, uint16_t port) lwip_close(sockfd); } +bool DataStreamer :: openPaletteSocket() +{ + NetworkInterface *intf = NetworkInterface :: getInterface(0); + if (!intf) { + return false; + } + int sock = socket(AF_INET, SOCK_DGRAM, 0); + if (sock < 0) { + return false; + } + // Leave through the interface the FPGA VIC stream uses, from its source + // port, so palette and video arrive from one address and port. Left to + // itself, lwIP would route unicast over whichever interface it prefers. + struct ifreq iface; + memset(&iface, 0, sizeof(iface)); + intf->getNetifName(iface.ifr_name, sizeof(iface.ifr_name)); + struct sockaddr_in local; + memset(&local, 0, sizeof(local)); + local.sin_family = AF_INET; + local.sin_addr.s_addr = INADDR_ANY; + local.sin_port = htons(53248); + if ((setsockopt(sock, SOL_SOCKET, SO_BINDTODEVICE, &iface, sizeof(iface)) < 0) || + (bind(sock, (const struct sockaddr *)&local, sizeof(local)) < 0)) { + lwip_close(sock); + return false; + } + palette_socket = sock; + palette_socket_dest_ip = 0; + palette_socket_dest_port = 0; + return true; +} + +void DataStreamer :: closePaletteSocket() +{ + if (palette_socket >= 0) { + lwip_close(palette_socket); + palette_socket = -1; + } +} + +// Returns whether a send was attempted, so that failed attempts are paced too. +bool DataStreamer :: sendVicPalette() +{ + taskENTER_CRITICAL(); + const bool requested = palette_stream_requested && streams[0].enable; + const uint32_t dest_ip = streams[0].dest_ip; + const int dest_port = streams[0].dest_port; + taskEXIT_CRITICAL(); + + if (!requested) { + closePaletteSocket(); + return false; + } + if ((palette_socket < 0) && !openPaletteSocket()) { + return true; + } + // Connected, so lwIP accepts datagrams only from the destination, and + // drained below, so not even those can pin its few netbufs. + if ((palette_socket_dest_ip != dest_ip) || (palette_socket_dest_port != dest_port)) { + struct sockaddr_in destination; + memset(&destination, 0, sizeof(destination)); + destination.sin_family = AF_INET; + destination.sin_addr.s_addr = dest_ip; + destination.sin_port = htons(dest_port); + if (connect(palette_socket, (const struct sockaddr *)&destination, sizeof(destination)) < 0) { + closePaletteSocket(); + return true; + } + palette_socket_dest_ip = dest_ip; + palette_socket_dest_port = dest_port; + } + + uint8_t packet[60] = { 0 }; + uint8_t rgb[16][3]; + const uint16_t generation = U64Config::get_palette_rgb(rgb); + packet[0] = (uint8_t)generation; + packet[1] = (uint8_t)(generation >> 8); + packet[4] = 239; // reserved line number + packet[6] = 0x80; // 384 pixels per line, little endian + packet[7] = 0x01; + packet[8] = 1; // one palette + packet[9] = 4; // four bits per VIC color index + packet[10] = 1; // palette packet type indicator + memcpy(packet + 12, rgb, sizeof(rgb)); + + // A failed send leaves nothing behind on a UDP socket; the next wake + // simply tries again. + send(palette_socket, packet, sizeof(packet), 0); + + uint8_t discard[16]; + while (recv(palette_socket, discard, sizeof(discard), MSG_DONTWAIT) > 0) + ; + return true; +} + +void DataStreamer :: wakePaletteTask() +{ + if (palette_task_handle) { + xTaskNotifyGive(palette_task_handle); + } +} + +void DataStreamer :: vicPaletteChanged() +{ + if (palette_stream_requested) { + wakePaletteTask(); + } +} + +void DataStreamer :: paletteTask() +{ + const TickType_t repeat_ticks = pdMS_TO_TICKS(1000); + // A send happens partway through a tick, so counting whole ticks takes one + // more than 20 ms to keep two packets out of one PAL or NTSC frame. + const TickType_t minimum_ticks = pdMS_TO_TICKS(20) + 1; + TickType_t last_send = 0; + bool sent = false; + + while (true) { + // Notifications latch: a start between this read and the wait still + // ends the wait at once. + const TickType_t wait = palette_stream_requested ? repeat_ticks : portMAX_DELAY; + const uint32_t notified = ulTaskNotifyTake(pdTRUE, wait); + if (notified && sent) { + const TickType_t elapsed = xTaskGetTickCount() - last_send; + if (elapsed < minimum_ticks) { + vTaskDelay(minimum_ticks - elapsed); + } + } + if (sendVicPalette()) { + last_send = xTaskGetTickCount(); + sent = true; + } + } +} + void DataStreamer :: calculate_udp_headers(int id) { @@ -369,7 +547,7 @@ void DataStreamer :: calculate_udp_headers(int id) header[30] = (uint8_t)(stream->dest_ip >> 0); // destination mac - if ((stream->dest_ip & 0x000000F8) == 0x000000E8) { + if ((stream->dest_ip & 0x000000F0) == 0x000000E0) { header[3] = header[31] & 0x7F; header[4] = header[32]; header[5] = header[33]; diff --git a/software/io/network/data_streamer.h b/software/io/network/data_streamer.h index a09093c34..9c3cb657b 100644 --- a/software/io/network/data_streamer.h +++ b/software/io/network/data_streamer.h @@ -27,6 +27,10 @@ typedef struct { uint8_t enable; } stream_config_t; +// Or'ed into SubsysCommand::mode to start the VIC stream with runtime palette +// packets. The stream ID is the low byte. +#define STREAM_MODE_PALETTE 0x100 + class DataStreamer : public ObjectWithMenu { @@ -43,16 +47,27 @@ class DataStreamer : public ObjectWithMenu uint8_t my_mac[6]; uint32_t my_ip; + volatile bool palette_stream_requested; + TaskHandle_t palette_task_handle; + int palette_socket; + uint32_t palette_socket_dest_ip; + int palette_socket_dest_port; stream_config_t streams[4]; TimerHandle_t timers[4]; static void S_timer(TimerHandle_t a); + static void S_palette_task(void *context); SubsysResultCode_e startStream(SubsysCommand *cmd); SubsysResultCode_e stopStream(SubsysCommand *cmd); void calculate_udp_headers(int id); void send_udp_packet(uint32_t ip, uint16_t port); + bool openPaletteSocket(); + void closePaletteSocket(); + bool sendVicPalette(); + void paletteTask(); + void wakePaletteTask(); public: DataStreamer(); virtual ~DataStreamer(); @@ -60,6 +75,8 @@ class DataStreamer : public ObjectWithMenu static SubsysResultCode_e S_startStream(SubsysCommand *cmd); static SubsysResultCode_e S_stopStream(SubsysCommand *cmd); + void vicPaletteChanged(); + // from ObjectWithMenu void create_task_items(void); void update_task_items(bool writablePath); diff --git a/software/io/network/network_interface.cc b/software/io/network/network_interface.cc index c495c3c88..ff6110aa2 100644 --- a/software/io/network/network_interface.cc +++ b/software/io/network/network_interface.cc @@ -459,6 +459,13 @@ void NetworkInterface :: getMacAddr(uint8_t *buf) memcpy(buf, &my_net_if.hwaddr, 6); } +// The name netif_find() looks up, and so the one SO_BINDTODEVICE takes: +// the interface's two letters followed by its number. +void NetworkInterface :: getNetifName(char *name, int size) +{ + snprintf(name, size, "%c%c%u", my_net_if.name[0], my_net_if.name[1], my_net_if.num); +} + void NetworkInterface :: setIpAddr(uint8_t *buf) { memcpy(&my_ip.addr, &buf[0], 4); diff --git a/software/io/network/network_interface.h b/software/io/network/network_interface.h index 5d763ef58..6596c97e8 100644 --- a/software/io/network/network_interface.h +++ b/software/io/network/network_interface.h @@ -136,6 +136,7 @@ class NetworkInterface : protected ConfigurableObject void getIpAddr(uint8_t *a); void getMacAddr(uint8_t *a); + void getNetifName(char *name, int size); void setIpAddr(uint8_t *a); char *getIpAddrString(char *buf, int buflen); bool peekArpTable(uint32_t ipToQuery, uint8_t *mac); diff --git a/software/u64/u64_config.cc b/software/u64/u64_config.cc index d4edec4a7..6ec08adbc 100755 --- a/software/u64/u64_config.cc +++ b/software/u64/u64_config.cc @@ -21,6 +21,7 @@ extern "C" { #include "product.h" #include "userinterface.h" #include "u64_config.h" +#include "data_streamer.h" #include "audio_select.h" #include "fpll.h" #include "i2c_drv.h" @@ -71,6 +72,7 @@ const uint8_t default_colors[16][3] = { static uint8_t active_palette[16][3]; static bool active_palette_valid = false; +static uint16_t active_palette_generation = 0; // static pointer U64Config *u64_configurator = NULL; @@ -2765,24 +2767,45 @@ static void program_palette_color(uint8_t index, const uint8_t rgb[3]) void U64Config :: set_palette_rgb(const uint8_t rgb[16][3]) { + // The registers are written inside the same critical section, so that + // what the VIC shows, GET_PALETTE and the streamed generation cannot + // disagree when two writers race. That is 16 colours of register writes + // and integer YUV conversion; nothing in it blocks. + taskENTER_CRITICAL(); memcpy(active_palette, rgb, sizeof(active_palette)); active_palette_valid = true; + active_palette_generation++; program_palette_rgb(rgb); + taskEXIT_CRITICAL(); + if (dataStreamer) { + dataStreamer->vicPaletteChanged(); + } } -void U64Config :: get_palette_rgb(uint8_t rgb[16][3]) +uint16_t U64Config :: get_palette_rgb(uint8_t rgb[16][3]) { + // Pair the generation and RGB bytes from one complete palette snapshot. + taskENTER_CRITICAL(); memcpy(rgb, active_palette_valid ? active_palette : default_colors, sizeof(active_palette)); + const uint16_t generation = active_palette_generation; + taskEXIT_CRITICAL(); + return generation; } void U64Config :: set_palette_color(uint8_t index, const uint8_t rgb[3]) { + taskENTER_CRITICAL(); if (!active_palette_valid) { memcpy(active_palette, default_colors, sizeof(active_palette)); active_palette_valid = true; } memcpy(active_palette[index], rgb, 3); + active_palette_generation++; program_palette_color(index, rgb); + taskEXIT_CRITICAL(); + if (dataStreamer) { + dataStreamer->vicPaletteChanged(); + } } void U64Config :: reset_palette() diff --git a/software/u64/u64_config.h b/software/u64/u64_config.h index d40e1a134..3d26ca2ff 100755 --- a/software/u64/u64_config.h +++ b/software/u64/u64_config.h @@ -161,7 +161,7 @@ class U64Config : public ConfigurableObject, ObjectWithMenu, SubSystem static void get_sid_addresses(ConfigStore *cfg, uint8_t *base, uint8_t *mask, uint8_t *split); static void fix_splits(uint8_t *base, uint8_t *mask, uint8_t *split); static void list_palettes(ConfigItem *it, IndexedList& strings); - static void get_palette_rgb(uint8_t rgb[16][3]); + static uint16_t get_palette_rgb(uint8_t rgb[16][3]); static void set_palette_rgb(const uint8_t rgb[16][3]); static void set_palette_color(uint8_t index, const uint8_t rgb[3]); static void reset_palette(); diff --git a/tests/e2e/io/command_interface/uci_targets_test.py b/tests/e2e/io/command_interface/uci_targets_test.py index bad0c75a7..b1d08dbf0 100755 --- a/tests/e2e/io/command_interface/uci_targets_test.py +++ b/tests/e2e/io/command_interface/uci_targets_test.py @@ -10,8 +10,9 @@ It also covers the transport state machine, the control target's rejection paths, and reply framing on the SoftIEC target, whose single-part replies were announced as "Data More" and left a client waiting for a block that is never sent. On -Ultimate 64 hardware it verifies the runtime RGB palette commands and restores -the palette before exiting. +Ultimate 64 hardware it verifies the runtime RGB palette commands, and the +palette packets a VIC stream opted into with palette=1 carries, and restores the +palette before exiting. Every expected value here was taken from the firmware and confirmed against a real device. The manuals under doc/ ("Ultimate Command Interface - Register API", @@ -43,8 +44,12 @@ import argparse import ftplib +import itertools import json +import select +import socket import sys +import threading import time import urllib.error import urllib.parse @@ -55,9 +60,13 @@ sys.path.insert(0, str(next(p for p in Path(__file__).resolve().parents if (p / "tests" / "lib").is_dir()) / "tests" / "lib")) import bootstrap # noqa: E402,F401 +import assembler # noqa: E402 import cli # noqa: E402 +from api import UltimateApi # noqa: E402 import ftp as ftp_lib import rest as rest_lib +import machine as machine_lib +import streams as stream_lib import targets from report import ( FAIL, Failure, OK, SKIP, check, check_skip, check_start, detail, @@ -119,6 +128,22 @@ CTRL_CMD_SET_PALETTE = 0x52 CTRL_CMD_SET_PALETTE_COLOR = 0x53 CTRL_CMD_RESET_PALETTE = 0x54 +# The debug stream's default destination, from the firmware's stream settings. +DEBUG_GROUP = "239.0.1.66" +DEBUG_PORT = 11002 +# Longer than the one-second palette repeat, so a leaked opt-in cannot hide. +PALETTE_QUIET_SECONDS = 1.5 +# From the device answering to the palette packet arriving. The repeat is a +# second, so only a send on the event itself makes it this soon. +PALETTE_PROMPT_SECONDS = 0.3 +# A gap in the video longer than this is the stream stopping, not the network. +VIDEO_STALL_SECONDS = 0.1 +PALETTE_BURST_SOURCE = Path(__file__).with_name("vic_palette_burst.asm") +PALETTE_BURST_STATUS = 0xC000 +PALETTE_BURST_READY = 0xA4 +PALETTE_BURST_RUNNING = 0xA5 +PALETTE_BURST_DONE = 0x5A +PALETTE_BURST_GO = 0xC003 SOFTIEC_CMD_IDENTIFY = 0x01 SOFTIEC_CMD_LOAD_SU = 0x10 SOFTIEC_CMD_GET_FATNAME = 0x22 @@ -218,6 +243,7 @@ "get-drvinfo", "softiec-x00-name", "softiec-setting-modes", + "palette-stream", "interface-usable-after", ] @@ -252,13 +278,26 @@ def __init__(self, host: str, password: str | None, timeout: float) -> None: self.password = password self.timeout = timeout + def machine(self) -> machine_lib.Machine: + """Which machine this is, asked once of the device's /v1/info.""" + def fetch() -> tuple[str, str]: + status, body = self.request("GET", "/v1/info", repeatable=True) + if status != 200: + raise Failure(f"GET /v1/info returned HTTP {status}: {body[:200]!r}") + info = json.loads(body) + return info.get("product", ""), info.get("firmware_version", "") + return machine_lib.identify(self.host, fetch) + def request(self, method: str, path: str, params: dict[str, object] | None = None, - repeatable: bool = False, host: str | None = None) -> tuple[int, bytes]: + repeatable: bool = False, host: str | None = None, + data: bytes | None = None) -> tuple[int, bytes]: url = f"http://{host or self.target.host_for(path)}{path}" if params: url += "?" + urllib.parse.urlencode(params) headers = {"X-Password": self.password} if self.password else {} - request = urllib.request.Request(url, headers=headers, method=method) + if data is not None: + headers["Content-Type"] = "application/octet-stream" + request = urllib.request.Request(url, data=data, headers=headers, method=method) # Transport and retry policy come from tests/lib/rest.py; see # rest.may_retry. `repeatable` is this suite's word for idempotent: a # register read applies nothing, so it may go again after the request @@ -277,14 +316,17 @@ def request(self, method: str, path: str, params: dict[str, object] | None = Non except (OSError, TimeoutError, urllib.error.URLError) as exc: raise Failure(f"{method} {url} failed: {format_exception(exc)}") from exc - def peek(self, address: int, repeatable: bool = False) -> int: + def readmem(self, address: int, length: int, repeatable: bool = False) -> bytes: status, body = self.request( - "GET", READMEM_PATH, params={"address": f"{address:04x}", "length": 1}, + "GET", READMEM_PATH, params={"address": f"{address:04x}", "length": length}, repeatable=repeatable, host=self.register_host ) - if status != 200 or len(body) != 1: + if status != 200 or len(body) != length: raise Failure(f"readmem(${address:04X}) failed with HTTP {status}: {body[:200]!r}") - return body[0] + return body + + def peek(self, address: int, repeatable: bool = False) -> int: + return self.readmem(address, 1, repeatable)[0] def poke(self, address: int, value: int) -> None: status, body = self.request( @@ -329,6 +371,11 @@ def reset(self) -> None: if status != 200: raise Failure(f"reset failed with HTTP {status}: {body[:200]!r}") + def run_prg(self, program: bytes) -> None: + status, body = self.request("POST", "/v1/runners:run_prg", data=program) + if status != 200: + raise Failure(f"runners:run_prg returned HTTP {status}: {body[:200]!r}") + class FtpFixture: """Files this suite puts on the device, over FTP because REST cannot delete.""" @@ -709,6 +756,409 @@ def run_palette(uci: Uci) -> bool: return True +class VicListener: + """Drain the VIC stream on a thread of its own. + + A REST call with the stream running takes 80 ms or more, and one PAL frame + is 50 datagrams every 20 ms, so a socket read only between calls overflows + any receive buffer Linux grants without raising `net.core.rmem_max`. A + thread keeps it drained whatever the main thread is waiting on. + """ + + def __init__(self, sock: socket.socket, addresses: set[str]) -> None: + self.sock = sock + self.addresses = addresses + self.lock = threading.Lock() + self.palettes: list[tuple[float, int, bytes]] = [] + self.video: list[tuple[float, int]] = [] + self.foreign: set[str] = set() + self.error: Failure | None = None + self._stop = threading.Event() + self._thread = threading.Thread(target=self._run, name="vic-listener", daemon=True) + + def _run(self) -> None: + while not self._stop.is_set(): + ready, _, _ = select.select([self.sock], (), (), 0.1) + if not ready: + continue + try: + data, sender = self.sock.recvfrom(2048) + except OSError: + continue + now = time.monotonic() + if sender[0] not in self.addresses: + with self.lock: + self.foreign.add(sender[0]) + continue + try: + palette = stream_lib.palette_packet(data) + except Failure as exc: + with self.lock: + self.error = self.error or exc + continue + with self.lock: + if palette is not None: + self.palettes.append((now, *palette)) + elif len(data) == stream_lib.PACKET_SIZE: + self.video.append((now, int.from_bytes(data[:2], "little"))) + + def start(self) -> None: + self._thread.start() + + def stop(self) -> None: + self._stop.set() + self._thread.join(timeout=2.0) + + def raise_error(self) -> None: + with self.lock: + if self.error is not None: + raise self.error + + def palettes_since(self, since: float) -> list[tuple[float, int, bytes]]: + with self.lock: + return [p for p in self.palettes if p[0] >= since] + + def video_since(self, since: float) -> list[tuple[float, int]]: + with self.lock: + return [v for v in self.video if v[0] >= since] + + def wait_palette(self, since: float, expected: bytes | None = None, + timeout: float = 2.0) -> tuple[float, int, bytes]: + """The first palette packet after `since` carrying `expected`, if given.""" + deadline = time.monotonic() + timeout + while True: + self.raise_error() + for packet in self.palettes_since(since): + if expected is None or packet[2] == expected: + return packet + if time.monotonic() >= deadline: + seen = self.palettes_since(since) + if seen: + raise Failure(f"last palette packet carried {seen[-1][2].hex()}, " + f"expected {expected.hex() if expected else 'any'}") + raise Failure(f"no palette packet arrived within {timeout:g} seconds") + time.sleep(0.01) + + def expect_no_palette(self, seconds: float = PALETTE_QUIET_SECONDS) -> None: + """Listen longer than one repeat, with video as the positive control.""" + since = time.monotonic() + time.sleep(seconds) + self.raise_error() + video = self.video_since(since) + if len(video) < 100: + raise Failure(f"only {len(video)} video packets in {seconds:g} s, so the " + f"absence of palette packets proves nothing") + palettes = self.palettes_since(since) + if palettes: + raise Failure(f"{len(palettes)} palette packets arrived on a stream that " + f"did not ask for them") + + +# More opted-in start/stop cycles than lwIP has UDP sockets (MEMP_NUM_UDP_PCB 8). +PALETTE_SOCKET_CYCLES = 20 +# A multicast group in 224-231, which #871 moved from unicast to multicast. +PALETTE_LOW_MULTICAST_GROUP = "230.0.1.64" + + +def expected_burst_color(index: int) -> bytes: + """Color 6 after the burst fixture's `index`th change, counting from 0.""" + return bytes(((13 * index) & 0xFF, (85 + 29 * index) & 0xFF, (170 + 47 * index) & 0xFF)) + + +def run_palette_stream(session: RestSession, uci: Uci) -> bool: + """Runtime palette packets on the VIC stream (#850), opted into with palette=1. + + Runs after the command-interface scenarios, so a machine that cannot stream + to this host costs nothing but this scenario. + """ + scenario = "palette-stream" + _, probe_status = uci.transact(bytes([TARGET_CONTROL, CTRL_CMD_GET_PALETTE])) + if probe_status == STATUS_UNKNOWN_COMMAND: + check_start(f"{scenario}: the machine has a runtime palette") + check_skip("GET_PALETTE is not served") + return True + original, text = uci.transact(bytes([TARGET_CONTROL, CTRL_CMD_GET_PALETTE])) + if text != STATUS_OK or len(original) != 48: + raise Failure(f"{scenario}: GET_PALETTE returned {len(original)} bytes, status {text!r}") + + video_address = f"{session.target.video_group}:{session.target.video_port}" + machine = session.machine() + if not machine.has_data_streams: + check_start(f"{scenario}: the machine serves the VIC stream") + check_skip(f"{machine.described} has no VIC stream") + return True + # Declared, not probed: a probe would skip exactly when the feature broke. + offered = f"{scenario}: the firmware offers palette streams" + if machine.skip_without_fix(machine_lib.VIC_PALETTE_STREAM, offered): + return True + with check(offered): + status, body = session.request("PUT", "/v1/streams/video:start", + params={"ip": video_address, "palette": 2}) + if status != 400 or b"Palette must be 0 or 1" not in body: + raise Failure(f"video:start with palette=2 returned HTTP {status}: {body[:200]!r}; " + f"expected 400 'Palette must be 0 or 1'") + + api = UltimateApi(session.target, session.password) + arming = stream_lib.Arming(api, session.target) + addresses = stream_lib.source_addresses(session.target) + if not addresses: + raise Failure(f"{scenario}: could not resolve the VIC stream source address") + sock = stream_lib.stream_socket(session.target.video_group, session.target.video_port) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 4 * 1024 * 1024) + listener = VicListener(sock, addresses) + listener.start() + fixture_loaded = False + fixture_released = False + try: + # Whether this host can see the stream at all is a property of the + # bench, not of the firmware, so it decides a skip rather than a failure. + if not arming.start("video", palette=0): + reason = arming.failures.get("video", "") + if "No Operational Network Interface" not in reason: + raise Failure(f"{scenario}: video:start failed: {reason}") + skip = "the VIC stream's network interface has no link" + else: + since = time.monotonic() + time.sleep(1.0) + skip = None + if len(listener.video_since(since)) < 100: + with listener.lock: + foreign = sorted(listener.foreign) + skip = (f"the VIC stream arrives from {', '.join(foreign)}, not from " + f"{', '.join(sorted(addresses))}; name that address with -H" + if foreign else "no VIC stream packets reach this host") + if skip: + check_start(f"{scenario}: the VIC stream reaches this host") + check_skip(skip) + return True + + with check(f"{scenario}: a refused palette value leaves the debug stream running"): + arming.stop("video") + debug_sock = stream_lib.stream_socket(DEBUG_GROUP, DEBUG_PORT) + try: + status, body = session.request("PUT", "/v1/streams/debug:start", + params={"ip": f"{DEBUG_GROUP}:{DEBUG_PORT}"}) + if status != 200: + raise Failure(f"debug:start returned HTTP {status}: {body[:200]!r}") + for stream, value in (("video", 2), ("audio", 1), ("audio", 0)): + status, body = session.request( + "PUT", f"/v1/streams/{stream}:start", + params={"ip": video_address, "palette": value}) + if status != 400: + raise Failure(f"{stream} stream with palette={value} returned " + f"HTTP {status}: {body[:200]!r}") + tuple(stream_lib.receive([debug_sock], addresses, 0.1)) + debug = sum(1 for _, _, mine in stream_lib.receive([debug_sock], addresses, 0.5) + if mine) + if not debug: + raise Failure("the debug stream stopped for a request that was refused") + finally: + session.request("PUT", "/v1/streams/debug:stop") + debug_sock.close() + if not arming.start("video", palette=0): + raise Failure(f"video:start failed: {arming.failures.get('video')}") + + with check(f"{scenario}: palette=0 sends no palette packets"): + listener.expect_no_palette() + + with check(f"{scenario}: a palette change on an ordinary stream sends nothing"): + changed = bytearray(original) + changed[-3:] = bytes(component ^ 0x5A for component in changed[-3:]) + reply, text = uci.transact( + bytes([TARGET_CONTROL, CTRL_CMD_SET_PALETTE_COLOR, 15]) + changed[-3:]) + if text != STATUS_OK or reply: + raise Failure(f"SET_PALETTE_COLOR returned data {reply!r}, status {text!r}") + listener.expect_no_palette() + + with check(f"{scenario}: an opted-in start sends the current palette at once"): + arming.stop("video") + requested = time.monotonic() + if not arming.start("video", palette=1): + raise Failure(f"video:start with palette=1 failed: {arming.failures.get('video')}") + answered = time.monotonic() + arrived, generation, _ = listener.wait_palette(requested, bytes(changed)) + if arrived - answered > PALETTE_PROMPT_SECONDS: + raise Failure(f"the first palette packet came {arrived - answered:.2f} s after " + f"the start was answered") + + with check(f"{scenario}: the palette repeats once a second"): + first = listener.wait_palette(arrived + 0.01, bytes(changed), timeout=2.0) + second = listener.wait_palette(first[0] + 0.01, bytes(changed), timeout=2.0) + interval = second[0] - first[0] + if {first[1], second[1]} != {generation}: + raise Failure(f"repeats carried generations {first[1]} and {second[1]}, " + f"expected {generation}") + if not 0.8 <= interval <= 1.3: + raise Failure(f"repeats came {interval:.2f} s apart") + + with check(f"{scenario}: a palette change is sent at once"): + previous = listener.wait_palette(second[0] + 0.01, bytes(changed), timeout=2.0) + changed[-3:] = bytes(component ^ 0xFF for component in changed[-3:]) + reply, text = uci.transact( + bytes([TARGET_CONTROL, CTRL_CMD_SET_PALETTE_COLOR, 15]) + changed[-3:]) + done = time.monotonic() + if text != STATUS_OK or reply: + raise Failure(f"SET_PALETTE_COLOR returned data {reply!r}, status {text!r}") + arrived, generation, _ = listener.wait_palette(previous[0] + 0.01, bytes(changed)) + if arrived - done > PALETTE_PROMPT_SECONDS: + raise Failure(f"the changed palette came {arrived - done:.2f} s after the change") + # Only a send on change can arrive before the next repeat is due. + if done - previous[0] < 0.6 and arrived - previous[0] > 0.9: + raise Failure(f"the changed palette came {arrived - previous[0]:.2f} s after the " + f"last repeat, which is when the repeat was due") + + with check(f"{scenario}: rapid changes are coalesced without stopping the video"): + session.poke(PALETTE_BURST_GO, 0) + session.poke(PALETTE_BURST_STATUS, 0) + session.run_prg(assembler.assemble(PALETTE_BURST_SOURCE)) + fixture_loaded = True + deadline = time.monotonic() + 5.0 + while session.peek(PALETTE_BURST_STATUS, repeatable=True) != PALETTE_BURST_READY: + if time.monotonic() >= deadline: + raise Failure("the burst fixture did not reach its ready state") + time.sleep(0.05) + baseline_palette, text = uci.transact(bytes([TARGET_CONTROL, CTRL_CMD_GET_PALETTE])) + if text != STATUS_OK: + raise Failure(f"GET_PALETTE before the burst returned {text!r}") + _, baseline, _ = listener.wait_palette(time.monotonic(), baseline_palette) + started = time.monotonic() + session.poke(PALETTE_BURST_GO, 1) + deadline = started + 15.0 + while True: + result = session.readmem(PALETTE_BURST_STATUS, 3, repeatable=True) + if result[0] == PALETTE_BURST_DONE: + break + if result[0] not in (PALETTE_BURST_READY, PALETTE_BURST_RUNNING): + raise Failure(f"the burst fixture reported status ${result[0]:02X}") + if time.monotonic() >= deadline: + raise Failure("the burst did not finish within 15 seconds") + time.sleep(0.05) + finished = time.monotonic() + session.poke(PALETTE_BURST_GO, 2) + fixture_released = True + count = int.from_bytes(result[1:3], "little") + final_palette, text = uci.transact(bytes([TARGET_CONTROL, CTRL_CMD_GET_PALETTE])) + if text != STATUS_OK: + raise Failure(f"GET_PALETTE after the burst returned {text!r}") + final = listener.wait_palette(started, final_palette) + samples = [p for p in listener.palettes_since(started) if p[0] <= final[0]] + video = [v for v in listener.video_since(started) if v[0] <= finished] + + delta = (final[1] - baseline) & 0xFFFF + if count < 2 or delta != count: + raise Failure(f"{count} changes advanced the generation by {delta}") + if final_palette[18:21] != expected_burst_color(count - 1): + raise Failure(f"color 6 ended as {final_palette[18:21].hex()}, " + f"expected {expected_burst_color(count - 1).hex()}") + for _, sample_generation, sample_palette in samples: + change = (sample_generation - baseline) & 0xFFFF + if 1 <= change <= count and sample_palette[18:21] != expected_burst_color(change - 1): + raise Failure(f"generation {sample_generation} carried color 6 as " + f"{sample_palette[18:21].hex()}, expected " + f"{expected_burst_color(change - 1).hex()}") + distinct = len({sample[1] for sample in samples}) + if distinct >= count: + raise Failure(f"{count} changes produced {distinct} distinct palette packets; " + f"nothing was coalesced") + span = samples[-1][0] - samples[0][0] if len(samples) > 1 else 0.0 + rate = (len(samples) - 1) / span if span > 0 else 0.0 + # Host scheduling can deliver two queued datagrams back to back, so + # the sustained rate is the stable measure of the wire rate. + if rate > 55.0: + raise Failure(f"palette packets came at {rate:.1f} per second, expected at most 55") + # Datagrams the host drops are the host's; the firmware's part is to + # keep the video coming, so a stall is what fails. + stall = max((b[0] - a[0] for a, b in itertools.pairwise(video)), default=0.0) + if len(video) < 100 or stall > VIDEO_STALL_SECONDS: + raise Failure(f"{len(video)} video packets during the burst, longest gap " + f"{stall * 1000:.0f} ms") + lost = sum(((b[1] - a[1]) & 0xFFFF) - 1 for a, b in itertools.pairwise(video) + if ((b[1] - a[1]) & 0xFFFF) < 0x8000) + detail(f"{count} changes in {finished - started:.2f} s -> {distinct} palette " + f"packets at {rate:.1f}/s; {len(video)} video packets, {lost} lost on " + f"the way here, longest gap {stall * 1000:.0f} ms") + + with check(f"{scenario}: RESET_PALETTE reaches an opted-in client at once"): + session.reset() + uci.release() + fixture_loaded = False + requested = time.monotonic() + reply, text = uci.transact(bytes([TARGET_CONTROL, CTRL_CMD_RESET_PALETTE])) + done = time.monotonic() + if text != STATUS_OK or reply: + raise Failure(f"RESET_PALETTE returned data {reply!r}, status {text!r}") + default_palette, text = uci.transact(bytes([TARGET_CONTROL, CTRL_CMD_GET_PALETTE])) + if text != STATUS_OK: + raise Failure(f"GET_PALETTE after RESET_PALETTE returned {text!r}") + arrived, _, _ = listener.wait_palette(requested, default_palette) + if arrived - done > PALETTE_PROMPT_SECONDS: + raise Failure(f"the reset palette came {arrived - done:.2f} s after the reset") + + with check(f"{scenario}: a later ordinary start is not opted in"): + arming.stop("video") + if not arming.start("video"): + raise Failure(f"video:start failed: {arming.failures.get('video')}") + listener.expect_no_palette() + + with check(f"{scenario}: palette packets still arrive after " + f"{PALETTE_SOCKET_CYCLES} opted-in starts and stops"): + # Each opted-in start opens the palette socket and each stop closes + # it. lwIP has 8 UDP sockets in all, so a socket that is not given + # back runs the pool dry well within these cycles. + for cycle in range(1, PALETTE_SOCKET_CYCLES + 1): + arming.stop("video") + time.sleep(0.2) + requested = time.monotonic() + if not arming.start("video", palette=1): + raise Failure(f"cycle {cycle}: video:start with palette=1 failed: " + f"{arming.failures.get('video')}") + try: + listener.wait_palette(requested) + except Failure as exc: + raise Failure(f"cycle {cycle}: {exc}") from exc + arming.stop("video") + + low_group = PALETTE_LOW_MULTICAST_GROUP + with check(f"{scenario}: a stream to {low_group}, below 232.0.0.0, is sent as multicast"): + # #871 widened the multicast test from 232-239 to all of 224.0.0.0/4. + # Treated as unicast, a group in 224-231 has no ARP answer. + port = session.target.video_port + low_sock = stream_lib.stream_socket(low_group, port) + try: + status, body = session.request("PUT", "/v1/streams/video:start", + params={"ip": f"{low_group}:{port}"}) + if status != 200: + raise Failure(f"video:start to {low_group} returned HTTP {status}: " + f"{body[:200]!r}") + tuple(stream_lib.receive([low_sock], addresses, 0.1)) + arrived = sum(1 for _, _, mine in + stream_lib.receive([low_sock], addresses, 1.0) if mine) + if arrived < 100: + raise Failure(f"{arrived} video packets reached {low_group} in 1 s") + finally: + session.request("PUT", "/v1/streams/video:stop") + low_sock.close() + finally: + try: + if fixture_loaded and not fixture_released: + # The fixture waits for GO=1 and must not start pushing + # commands into the queue the restore below uses. + session.reset() + uci.release() + with check(f"{scenario}: restore the original runtime palette"): + reply, text = uci.transact(bytes([TARGET_CONTROL, CTRL_CMD_SET_PALETTE]) + original) + if text != STATUS_OK or reply: + raise Failure(f"restore returned data {reply!r}, status {text!r}") + actual, text = uci.transact(bytes([TARGET_CONTROL, CTRL_CMD_GET_PALETTE])) + if text != STATUS_OK or actual != original: + raise Failure(f"restored palette readback was {actual!r}") + finally: + arming.stop_all() + listener.stop() + sock.close() + return True + + def prime_reply_buffer(uci: Uci) -> None: """Leave a non-empty string in the control target's reply buffer. @@ -1145,6 +1595,7 @@ def run(name: str, fn, *fn_args) -> None: run("get-drvinfo", run_get_drvinfo, session, uci) run("softiec-x00-name", run_softiec_x00_name, ftp, uci) run("softiec-setting-modes", run_softiec_setting_modes, session, uci) + run("palette-stream", run_palette_stream, session, uci) run("interface-usable-after", run_interface_usable_after, uci) except Failure as exc: diff --git a/tests/e2e/io/command_interface/vic_palette_burst.asm b/tests/e2e/io/command_interface/vic_palette_burst.asm new file mode 100644 index 000000000..f96bd063a --- /dev/null +++ b/tests/e2e/io/command_interface/vic_palette_burst.asm @@ -0,0 +1,139 @@ +; Change VIC color 6 four times per frame through the Ultimate Command +; Interface. The host-side palette E2E test uses this to prove that firmware +; coalesces a burst without disturbing the ordinary video packet sequence. + +CTRL = $DF1C +CMDREG = $DF1D +STATR = $DF1F + +ST_STATE = $30 +ST_LAST = $20 +ST_STAT = $40 + +STATUS = $C000 ; $A4 ready, $A5 running, $5A complete +COUNT = $C001 ; completed commands, little endian +GO = $C003 ; host writes 1 to start the burst, 2 to exit + +FRAMES = 60 +CHANGES_PER_FRAME = 4 + + * = $0801 + +; BASIC line "10 SYS 2061". + .word basic_end, 10 + .byte $9e + .text "2061" + .byte 0 +basic_end + .word 0 + +start + sei + lda #$00 + sta COUNT + sta COUNT+1 + sta GO + lda #$A4 + sta STATUS +; Only 1 starts: the 2 that lets a finished burst exit must not start one. +wait_go + lda GO + cmp #$01 + bne wait_go + lda #$A5 + sta STATUS + lda #FRAMES + sta frames_left + +frame_loop + jsr wait_frame + lda #CHANGES_PER_FRAME + sta changes_left +change_loop + jsr set_color + inc red + lda red + clc + adc #12 ; net +13 including INC + sta red + lda green + clc + adc #29 + sta green + lda blue + clc + adc #47 + sta blue + inc COUNT + bne count_done + inc COUNT+1 +count_done + dec changes_left + bne change_loop + dec frames_left + bne frame_loop + + lda #$5A + sta STATUS + cli +wait_exit + lda GO + cmp #$02 + bne wait_exit + rts + +; Wait for raster line zero in the low half. $D012 also reads zero at line 256, +; so $D011 bit 7 distinguishes the real frame boundary. +wait_frame +wait_away + bit $D011 + bmi wait_away + lda $D012 + beq wait_away +wait_zero + bit $D011 + bmi wait_zero + lda $D012 + bne wait_zero + rts + +set_color +wait_idle + lda CTRL + and #ST_STATE + bne wait_idle + lda #$04 + sta CMDREG + lda #$53 + sta CMDREG + lda #$06 + sta CMDREG + lda red + sta CMDREG + lda green + sta CMDREG + lda blue + sta CMDREG + lda #$01 + sta CTRL +wait_reply + lda CTRL + and #ST_STATE + cmp #ST_LAST + bne wait_reply +drain_status + lda CTRL + and #ST_STAT + beq accept_reply + lda STATR + jmp drain_status +accept_reply + lda #$02 + sta CTRL + rts + +frames_left .byte 0 +changes_left .byte 0 +red .byte 0 +green .byte 85 +blue .byte 170 diff --git a/tests/e2e/lib/streams.py b/tests/e2e/lib/streams.py index 8b32ffac6..af813cd4c 100644 --- a/tests/e2e/lib/streams.py +++ b/tests/e2e/lib/streams.py @@ -222,14 +222,15 @@ def address(self, stream: str) -> str: def start(self, stream: str, already_arriving: bool = False, timeout: float | None = None, - retries: int | None = None) -> bool: + retries: int | None = None, **params: object) -> bool: """Ask the device to send `stream`, unless it already is. `already_arriving` is the caller saying it has seen packets from its own device at the standard address. That is the one thing about a stream that is not free to ask for twice, so a caller that finds it running issues no request at all and, by not having started it, leaves - it running afterwards. + it running afterwards. `params` go on the start request as they are, + such as `palette=1`. """ if already_arriving or stream in self.started: return False @@ -238,7 +239,7 @@ def start(self, stream: str, already_arriving: bool = False, # keep draining sockets bounds this call; see # rest.RestClient.request. self.api.streams.start(stream, ip=self.address(stream), - timeout=timeout, retries=retries) + timeout=timeout, retries=retries, **params) except Failure as exc: self.failures[stream] = str(exc) self.publish("start-failed", stream) @@ -346,6 +347,26 @@ def receive(sockets: Sequence[socket.socket], addresses: set[str], PAYLOAD_SIZE = LINES_PER_PACKET * BYTES_PER_LINE PACKET_SIZE = HEADER_SIZE + PAYLOAD_SIZE +# The runtime palette packet (#850), sent on the video port only to a stream +# started with palette=1: a video-style header on line 239, then the 16 RGB +# colors. Its 60 bytes cannot be mistaken for a 780-byte video packet. +PALETTE_PACKET_SIZE = HEADER_SIZE + 16 * 3 +PALETTE_LINE = 239 +_PALETTE_FORMAT = bytes([0x80, 0x01, 1, 4, 1, 0]) + + +def palette_packet(data: bytes) -> tuple[int, bytes] | None: + """`(generation, 48 RGB bytes)` for a palette packet, None for anything else. + + A datagram of that size on line 239 with other format bytes raises: it is + neither video nor a palette a client could use. + """ + if len(data) != PALETTE_PACKET_SIZE or int.from_bytes(data[4:6], "little") != PALETTE_LINE: + return None + if data[6:HEADER_SIZE] != _PALETTE_FORMAT: + raise Failure(f"palette packet has invalid format bytes {data[6:HEADER_SIZE].hex()}") + return int.from_bytes(data[:2], "little"), data[HEADER_SIZE:] + # The two frame heights the hardware actually produces: PAL is 272 lines, # NTSC is 240. The last packet's declared height is clamped to this range # (see _clamp_height) so a corrupt "line" field cannot grow the frame buffer diff --git a/tests/lib/machine.py b/tests/lib/machine.py index fdd7efd01..b6d113092 100644 --- a/tests/lib/machine.py +++ b/tests/lib/machine.py @@ -263,6 +263,17 @@ def _fix(name: str, behaviour: str, lacking: tuple[str, ...]) -> str: "hexadecimal, rather than parsing it as $0000 and acting there", (C64U, U2)) +# What the palette-stream scenario in tests/e2e/io/command_interface/uci_targets_test.py +# asserts (#850, #871). The C64 Ultimate's release firmware predates it. A +# machine missing from `lacking` that refuses `palette` on video:start fails +# the scenario rather than skipping it, so a regression cannot pass as "not +# offered here". +VIC_PALETTE_STREAM = _fix( + "vic-palette-stream", + "video:start takes palette=0 or 1, and a stream started with palette=1 " + "carries the runtime palette as packets beside the video", + (C64U,)) + # Every fix at once, for a sweep that asks whether the lagging line has caught # up rather than about one behaviour. ASSUME_ALL = "all"