diff --git a/projects/tzpuPico/esp32/filepack_version.txt b/projects/tzpuPico/esp32/filepack_version.txt index 6e272ad..995e96d 100644 --- a/projects/tzpuPico/esp32/filepack_version.txt +++ b/projects/tzpuPico/esp32/filepack_version.txt @@ -1 +1 @@ -2.49 +2.58 diff --git a/projects/tzpuPico/esp32/main/WiFi.cpp b/projects/tzpuPico/esp32/main/WiFi.cpp index dd75f3b..b4779ed 100644 --- a/projects/tzpuPico/esp32/main/WiFi.cpp +++ b/projects/tzpuPico/esp32/main/WiFi.cpp @@ -1619,6 +1619,159 @@ esp_err_t WiFi::defaultDataGETHandler(httpd_req_t *req) return cfgResult; } + else if (uriStr == "backup") + { + // Stream the entire SD card as a tar archive. + // Uses POSIX/ustar tar format: 512-byte header per file + padded data. + // Streamed via chunked HTTP so memory usage is constant regardless of SD size. + httpd_resp_set_type(req, "application/x-tar"); + httpd_resp_set_hdr(req, "Content-Disposition", "attachment; filename=\"picoZ80_backup.tar\""); + httpd_resp_set_hdr(req, "Cache-Control", "no-cache"); + + // Recursive lambda to walk directories and stream tar entries. + // Uses a stack-based approach to avoid deep recursion. + struct TarEntry { std::string path; bool isDir; long size; time_t mtime; }; + std::vector dirStack; + dirStack.push_back(std::string(pThis->wifiCtrl.run.fsPath)); + + char hdr[512]; + std::unique_ptr fbuf(new char[MAX_CHUNK_SIZE]); + if (!fbuf) { + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Memory allocation failed"); + return ESP_FAIL; + } + + esp_err_t tarResult = ESP_OK; + int fileCount = 0; + std::string basePath = pThis->wifiCtrl.run.fsPath; + if (basePath.back() != '/') basePath += '/'; + + while (!dirStack.empty() && tarResult == ESP_OK) + { + std::string curDir = dirStack.back(); + dirStack.pop_back(); + + DIR *d = opendir(curDir.c_str()); + if (!d) continue; + + // Collect entries first so we can close the dir handle quickly. + std::vector entries; + struct dirent *ent; + while ((ent = readdir(d)) != NULL) + { + if (strcmp(ent->d_name, ".") == 0 || strcmp(ent->d_name, "..") == 0) + continue; + std::string fullPath = curDir + "/" + ent->d_name; + struct stat st; + if (stat(fullPath.c_str(), &st) != 0) + continue; + TarEntry te; + te.path = fullPath; + te.isDir = S_ISDIR(st.st_mode); + te.size = te.isDir ? 0 : st.st_size; + te.mtime = st.st_mtime; + entries.push_back(te); + } + closedir(d); + + for (const auto &te : entries) + { + if (te.isDir) { + dirStack.push_back(te.path); + } + + // Build relative path by stripping the SD mount prefix. + std::string relPath = te.path; + if (relPath.find(basePath) == 0) + relPath = relPath.substr(basePath.length()); + if (te.isDir && relPath.back() != '/') + relPath += '/'; + + // Skip paths that are too long for tar (max 255 = 155 prefix + 100 name). + if (relPath.length() > 255) + continue; + + // Build the 512-byte POSIX ustar header. + memset(hdr, 0, 512); + + // Split into prefix (first 155 chars of dir) and name (last 100 chars). + std::string tarName, tarPrefix; + if (relPath.length() <= 100) { + tarName = relPath; + } else { + size_t splitAt = relPath.rfind('/', 99); + if (splitAt == std::string::npos) splitAt = 99; + tarPrefix = relPath.substr(0, splitAt); + tarName = relPath.substr(splitAt + 1); + } + strncpy(hdr + 0, tarName.c_str(), 100); + snprintf(hdr + 100, 8, "%07o", te.isDir ? 0755 : 0644); // mode + snprintf(hdr + 108, 8, "%07o", 0); // uid + snprintf(hdr + 116, 8, "%07o", 0); // gid + snprintf(hdr + 124, 12, "%011lo", te.isDir ? 0L : te.size); // size + snprintf(hdr + 136, 12, "%011lo", (long)te.mtime); // mtime + memset(hdr + 148, ' ', 8); // checksum placeholder + hdr[156] = te.isDir ? '5' : '0'; // typeflag + memcpy(hdr + 257, "ustar", 5); // magic + hdr[263] = '0'; hdr[264] = '0'; // version + if (!tarPrefix.empty()) + strncpy(hdr + 345, tarPrefix.c_str(), 155); + + // Compute header checksum (sum of all bytes, treating checksum field as spaces). + unsigned int cksum = 0; + for (int i = 0; i < 512; i++) + cksum += (unsigned char)hdr[i]; + snprintf(hdr + 148, 7, "%06o", cksum); + hdr[155] = '\0'; + + // Send header. + if (httpd_resp_send_chunk(req, hdr, 512) != ESP_OK) { + tarResult = ESP_FAIL; + break; + } + + // Send file data (not for directories). + if (!te.isDir && te.size > 0) { + FILE *f = fopen(te.path.c_str(), "rb"); + if (f) { + long remaining = te.size; + while (remaining > 0 && tarResult == ESP_OK) { + size_t toRead = (remaining > MAX_CHUNK_SIZE) ? MAX_CHUNK_SIZE : remaining; + size_t got = fread(fbuf.get(), 1, toRead, f); + if (got > 0) { + if (httpd_resp_send_chunk(req, fbuf.get(), got) != ESP_OK) + tarResult = ESP_FAIL; + remaining -= got; + } else { + break; + } + } + fclose(f); + + // Pad to 512-byte boundary. + int pad = (512 - (te.size % 512)) % 512; + if (pad > 0 && tarResult == ESP_OK) { + memset(hdr, 0, pad); + if (httpd_resp_send_chunk(req, hdr, pad) != ESP_OK) + tarResult = ESP_FAIL; + } + } + } + fileCount++; + } + } + + // End-of-archive: two 512-byte zero blocks. + if (tarResult == ESP_OK) { + memset(hdr, 0, 512); + httpd_resp_send_chunk(req, hdr, 512); + httpd_resp_send_chunk(req, hdr, 512); + httpd_resp_send_chunk(req, NULL, 0); + ESP_LOGI(WIFITAG, "Backup complete: %d files streamed as tar", fileCount); + } + + return tarResult; + } else if (uriStr == "wifistatus") { // JSON endpoint for AJAX polling of WiFi + system + RP2350 status. @@ -3699,9 +3852,9 @@ esp_err_t WiFi::sendFileManagerDir(httpd_req_t *req) htmlStr.append(" "); htmlStr.append(" "); htmlStr.append(" "); - htmlStr.append(" "); - htmlStr.append(" "); - htmlStr.append(" "); + htmlStr.append(" "); + htmlStr.append(" "); + htmlStr.append(" "); htmlStr.append(" "); htmlStr.append(" "); htmlStr.append(" "); @@ -3769,24 +3922,50 @@ esp_err_t WiFi::sendFileManagerDir(httpd_req_t *req) "\n"); } - int entryCnt = 1; - std::string fileToStat; + // Collect all directory entries, then sort: directories first, then alphabetical (case-insensitive). + struct t_DirEntry { + std::string name; + bool isDir; + long size; + }; + std::vector dirEntries; + while ((entry = readdir(dir)) != NULL) { - entrytype = (entry->d_type == DT_DIR ? "directory" : "file"); - fileToStat = sdpath + "/" + entry->d_name; + std::string fileToStat = sdpath + "/" + entry->d_name; if (stat(fileToStat.c_str(), &entryStat) == -1) { - ESP_LOGE(WIFITAG, "Failed to subdir stat %s : %s", entrytype, fileToStat.c_str()); + ESP_LOGE(WIFITAG, "Failed to stat %s", fileToStat.c_str()); continue; } - sprintf(entrysize, "%ld", entryStat.st_size); + t_DirEntry de; + de.name = entry->d_name; + de.isDir = (entry->d_type == DT_DIR); + de.size = entryStat.st_size; + dirEntries.push_back(de); + } + closedir(dir); + + std::sort(dirEntries.begin(), dirEntries.end(), [](const t_DirEntry &a, const t_DirEntry &b) { + // Directories first, then files. Within each group, case-insensitive alphabetical. + if (a.isDir != b.isDir) return a.isDir > b.isDir; + std::string al = a.name, bl = b.name; + std::transform(al.begin(), al.end(), al.begin(), ::tolower); + std::transform(bl.begin(), bl.end(), bl.begin(), ::tolower); + return al < bl; + }); + + int entryCnt = 1; + for (const auto &de : dirEntries) + { + entrytype = de.isDir ? "directory" : "file"; + sprintf(entrysize, "%ld", de.size); htmlStr.append(""); htmlStr.append(""); @@ -3806,14 +3985,14 @@ esp_err_t WiFi::sendFileManagerDir(httpd_req_t *req) // Slot 2: Open Dir (dirs) or Download (files) htmlStr.append(""); - if (entry->d_type == DT_DIR) + if (de.isDir) { htmlStr.append(""); htmlStr.append(""); htmlStr.append("d_name).append("\">"); + htmlStr.append(de.name).append("\">"); htmlStr.append(""); htmlStr.append(""); @@ -3822,7 +4001,7 @@ esp_err_t WiFi::sendFileManagerDir(httpd_req_t *req) { htmlStr.append("
"); htmlStr.append(""); - htmlStr.append("d_name).append("\">"); + htmlStr.append(""); htmlStr.append(""); htmlStr.append(""); @@ -3832,15 +4011,15 @@ esp_err_t WiFi::sendFileManagerDir(httpd_req_t *req) // Slot 3: Edit (text files only, blank otherwise) htmlStr.append(""); - if (entry->d_type != DT_DIR) + if (!de.isDir) { - std::filesystem::path filePath = entry->d_name; + std::filesystem::path filePath = de.name; if (filePath.extension() == ".txt" || filePath.extension() == ".htm" || filePath.extension() == ".js" || filePath.extension() == ".css" || filePath.extension() == ".json") { htmlStr.append(""); htmlStr.append(""); - htmlStr.append("d_name).append("\">"); + htmlStr.append(""); htmlStr.append(""); htmlStr.append(""); @@ -3853,7 +4032,7 @@ esp_err_t WiFi::sendFileManagerDir(httpd_req_t *req) htmlStr.append(""); htmlStr.append(""); htmlStr.append(""); - htmlStr.append("d_name).append("\">"); + htmlStr.append(""); htmlStr.append(""); htmlStr.append(""); @@ -3866,18 +4045,17 @@ esp_err_t WiFi::sendFileManagerDir(httpd_req_t *req) htmlStr.append(""); htmlStr.append(""); htmlStr.append(""); - htmlStr.append("d_name).append("\">"); + htmlStr.append(""); htmlStr.append(""); htmlStr.append(""); htmlStr.append(""); htmlStr.append(""); htmlStr.append("
\n"); entryCnt++; } - closedir(dir); htmlStr.append(" "); htmlStr.append("
NameTypeSize (Bytes)Name Type Size (Bytes) Action
"); htmlStr.append("
"); - htmlStr.append("d_name).append("\">"); - htmlStr.append("d_name).append("\">"); + htmlStr.append(""); + htmlStr.append(""); htmlStr.append(""); htmlStr.append(""); htmlStr.append("
"); @@ -4276,7 +4454,15 @@ esp_err_t WiFi::changeDiskHandler(httpd_req_t *req, enum DRIVETYPES driveType) .then(arr => { const tbody = document.getElementById('listing'); tbody.innerHTML = ''; - + + // Sort: ".." first, then directories alphabetically, then files alphabetically. + arr.sort((a, b) => { + if (a.name === '..') return -1; + if (b.name === '..') return 1; + if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1; + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + }); + arr.forEach(e => { const tr = document.createElement('tr'); const td = document.createElement('td'); diff --git a/projects/tzpuPico/esp32/main/tzpuPico.cpp b/projects/tzpuPico/esp32/main/tzpuPico.cpp index 84900ae..4b8dd37 100644 --- a/projects/tzpuPico/esp32/main/tzpuPico.cpp +++ b/projects/tzpuPico/esp32/main/tzpuPico.cpp @@ -321,6 +321,26 @@ void buildVersionList(WiFi::t_versionList *versionList, NVS &nvs, SDCard &sdcard static esp_netif_t *s_usb_ncm_netif = NULL; static volatile bool g_usbReady = false; // Set by USB task when setup completes. +// NCM connection parameters — read from JSON config "esp32.core" block. +// Defaults are used if the config doesn't specify them. +typedef struct { + bool enabled; // NCM enable/disable (default: true) + int maxRetries; // Max connection cycles: 0=forever, 5-1000 (default: 0) + int retryPeriod; // Delay between retries: 0=increasing backoff, 1-120s fixed (default: 0) +} t_ncmConfig; +static t_ncmConfig g_ncmConfig = { true, 0, 0 }; +#endif + +// WiFi enable flag — read from JSON config "esp32.core.wifi" (1=enable, 0=disable). +// Defaults to enabled if compiled in, disabled if not compiled in. +#if defined(CONFIG_IF_WIFI_ENABLED) +static bool g_wifiEnabled = true; +#else +static bool g_wifiEnabled = false; +#endif + +#if defined(CONFIG_IF_USB_NCM_ENABLED) + static void usb_ncm_l2_free(void *h, void *buffer) { free(buffer); @@ -362,18 +382,29 @@ static esp_err_t usb_ncm_recv_callback(void *buffer, uint16_t len, void *ctx) // CommandProcessor (SPI slave) without waiting for USB delays. The 2-second // disconnect/reconnect cycle that macOS needs would otherwise block SPI // slave init, causing the RP2350's first sector reads to fail. -// Maximum number of disconnect/connect cycles to attempt before giving up. -static constexpr int USB_NCM_MAX_RETRIES = 3; -// Time to hold the bus disconnected so the host tears down its NCM driver. -static constexpr int USB_NCM_DISCONNECT_MS = 2500; -// Time to wait after tud_connect() for the host to begin enumeration. -static constexpr int USB_NCM_CONNECT_SETTLE_MS = 500; -// Polling interval while waiting for tud_mounted(). -static constexpr int USB_NCM_POLL_MS = 100; -// Maximum time to wait for tud_mounted() after a connect before retrying. -static constexpr int USB_NCM_MOUNT_TIMEOUT_MS = 5000; -// Time to wait after mount for the host DHCP client to obtain its IP. -static constexpr int USB_NCM_DHCP_WAIT_MS = 3000; +// USB NCM connection timing parameters. +static constexpr int USB_NCM_DISCONNECT_INIT_MS = 3000; // Initial disconnect hold time. +static constexpr int USB_NCM_DISCONNECT_MAX_MS = 60000; // Maximum disconnect hold (backoff cap). +static constexpr int USB_NCM_BACKOFF_STEP_MS = 1000; // Increase disconnect by this much each retry. +static constexpr int USB_NCM_CONNECT_SETTLE_MS = 500; // Post-connect settle before polling mount. +static constexpr int USB_NCM_POLL_MS = 100; // Polling interval for mount/DHCP checks. +static constexpr int USB_NCM_MOUNT_TIMEOUT_MS = 5000; // Max wait for tud_mounted() per attempt. +static constexpr int USB_NCM_LINK_RETRIES = 3; // Link-state UP retries per mount cycle. +static constexpr int USB_NCM_POST_MOUNT_MS = 1500; // Delay after mount for host NCM driver load. +static constexpr int USB_NCM_DHCP_WAIT_MS = 3000; // DHCP wait per link-state attempt. +// Flag set when the DHCP server assigns a lease to the host. +static volatile bool g_ncmDhcpLeased = false; + +// Event handler: fired when the DHCP server assigns an IP to the connected host. +static void ncm_dhcp_event_handler(void *arg, esp_event_base_t event_base, + int32_t event_id, void *event_data) +{ + if (event_base == IP_EVENT && event_id == IP_EVENT_AP_STAIPASSIGNED) { + ip_event_ap_staipassigned_t *evt = (ip_event_ap_staipassigned_t *)event_data; + ESP_LOGI(MAINTAG, "USB NCM: DHCP lease assigned to host (IP " IPSTR ")", IP2STR(&evt->ip)); + g_ncmDhcpLeased = true; + } +} void setupUSBTask(void *pvParameters) { @@ -384,77 +415,40 @@ void setupUSBTask(void *pvParameters) esp_netif_init(); esp_event_loop_create_default(); + // Register for DHCP server lease events so we know when the host has an IP. + esp_event_handler_register(IP_EVENT, IP_EVENT_AP_STAIPASSIGNED, + &ncm_dhcp_event_handler, NULL); + // 1. Install TinyUSB driver on the OTG peripheral (GPIO 19/20). tinyusb_config_t tusb_cfg = {}; tusb_cfg.external_phy = false; esp_err_t ret = tinyusb_driver_install(&tusb_cfg); if (ret != ESP_OK) { ESP_LOGE(MAINTAG, "USB: TinyUSB driver install failed"); - g_usbReady = true; // Signal ready (even on failure) so main loop doesn't wait forever. + g_usbReady = true; vTaskDelete(NULL); return; } - // 2. Disconnect/reconnect with retry loop. macOS in particular can fail to - // enumerate an NCM device on the first attempt after a long power-off period - // because stale USB driver state persists in the kernel. Rather than using - // fixed blind delays, we poll tud_mounted() to confirm the host has actually - // completed enumeration, and retry the full cycle if it hasn't. - bool mounted = false; - for (int attempt = 1; attempt <= USB_NCM_MAX_RETRIES && !mounted; attempt++) + // 2. Initialise CDC-ACM for serial logging (independent of NCM connection). { - ESP_LOGI(MAINTAG, "USB: disconnect/connect attempt %d/%d", attempt, USB_NCM_MAX_RETRIES); - - // Disconnect — hold long enough for the host to fully tear down. - tud_disconnect(); - vTaskDelay(pdMS_TO_TICKS(USB_NCM_DISCONNECT_MS)); - - // Reconnect and let the bus settle. - tud_connect(); - vTaskDelay(pdMS_TO_TICKS(USB_NCM_CONNECT_SETTLE_MS)); - - // Poll tud_mounted() — the host has completed enumeration when this returns true. - int waited = 0; - while (waited < USB_NCM_MOUNT_TIMEOUT_MS) - { - if (tud_mounted()) - { - mounted = true; - ESP_LOGI(MAINTAG, "USB: host enumerated device after %d ms (attempt %d)", waited, attempt); - break; - } - vTaskDelay(pdMS_TO_TICKS(USB_NCM_POLL_MS)); - waited += USB_NCM_POLL_MS; - } - - if (!mounted) - { - ESP_LOGW(MAINTAG, "USB: mount timeout after %d ms (attempt %d), %s", - USB_NCM_MOUNT_TIMEOUT_MS, attempt, - attempt < USB_NCM_MAX_RETRIES ? "retrying..." : "giving up"); - } + tinyusb_config_cdcacm_t acm_cfg = {}; + ret = tusb_cdc_acm_init(&acm_cfg); + cdcOk = (ret == ESP_OK); } - if (!mounted) + // 3. Initialise the NCM network class handler. { - ESP_LOGE(MAINTAG, "USB: host did not enumerate device after %d attempts", USB_NCM_MAX_RETRIES); + tinyusb_net_config_t net_config = {}; + net_config.mac_addr[0] = 0x02; net_config.mac_addr[1] = 0x02; + net_config.mac_addr[2] = 0x11; net_config.mac_addr[3] = 0x22; + net_config.mac_addr[4] = 0x33; net_config.mac_addr[5] = 0x01; + net_config.on_recv_callback = usb_ncm_recv_callback; + ret = tinyusb_net_init(TINYUSB_USBDEV_0, &net_config); + ncmOk = (ret == ESP_OK); } - // 3. Initialise CDC-ACM for serial logging. - tinyusb_config_cdcacm_t acm_cfg = {}; - ret = tusb_cdc_acm_init(&acm_cfg); - cdcOk = (ret == ESP_OK); - - // 4. Initialise the NCM network class handler. - tinyusb_net_config_t net_config = {}; - net_config.mac_addr[0] = 0x02; net_config.mac_addr[1] = 0x02; - net_config.mac_addr[2] = 0x11; net_config.mac_addr[3] = 0x22; - net_config.mac_addr[4] = 0x33; net_config.mac_addr[5] = 0x01; - net_config.on_recv_callback = usb_ncm_recv_callback; - ret = tinyusb_net_init(TINYUSB_USBDEV_0, &net_config); - ncmOk = (ret == ESP_OK); - - // 5. Create the lwIP netif so received packets are handled immediately. + // 4. Create the lwIP netif and DHCP server (persists across reconnect cycles). if (ncmOk) { esp_netif_ip_info_t ip_info = {}; ip_info.ip.addr = ipaddr_addr(CONFIG_IF_USB_NCM_IP); @@ -491,45 +485,136 @@ void setupUSBTask(void *pvParameters) esp_netif_dhcps_option(s_usb_ncm_netif, ESP_NETIF_OP_SET, ESP_NETIF_IP_ADDRESS_LEASE_TIME, &lease_opt, sizeof(lease_opt)); esp_netif_action_start(s_usb_ncm_netif, 0, 0, 0); - - // Notify the USB host that the NCM network link is up. Without this - // the host never receives a ConnectionSpeedChange notification and will - // not start its DHCP client, leaving the interface in "no IP" state. - if (mounted) { - vTaskDelay(pdMS_TO_TICKS(200)); // Let netif settle before signalling host. - tud_network_link_state(0, true); - ESP_LOGI(MAINTAG, "USB NCM: link state set to UP"); - } } } - // 6. Redirect console to TinyUSB CDC-ACM. + // 5. Redirect console to TinyUSB CDC-ACM. if (cdcOk) { esp_tusb_init_console(TINYUSB_CDC_ACM_0); } - // 7. Wait for the host to complete DHCP and bring the network link up. - // Poll esp_netif_is_netif_up() rather than using a blind delay so we - // proceed as soon as the link is ready (or time out gracefully). - if (ncmOk && mounted && s_usb_ncm_netif != NULL) { - ESP_LOGI(MAINTAG, "USB NCM: waiting for host DHCP..."); - int dhcpWait = 0; - while (dhcpWait < USB_NCM_DHCP_WAIT_MS) { - if (esp_netif_is_netif_up(s_usb_ncm_netif)) { - ESP_LOGI(MAINTAG, "USB NCM: netif is up after %d ms", dhcpWait); + // 6. Signal ready so the main loop can start WiFi and the webserver immediately. + // The NCM connection will continue retrying in the background below. + ESP_LOGI(MAINTAG, "USB init complete (CDC:%s NCM:%s), starting NCM connection loop...", + cdcOk ? "OK" : "FAIL", ncmOk ? "OK" : "FAIL"); + g_usbReady = true; + + if (!ncmOk || s_usb_ncm_netif == NULL) { + ESP_LOGE(MAINTAG, "USB NCM: init failed, connection loop not started"); + vTaskDelete(NULL); + return; + } + + // 7. NCM connection loop. + // Reads config from g_ncmConfig (populated by JSON parser after g_usbReady is set). + // Allow a moment for the main loop to parse JSON config before we read g_ncmConfig. + vTaskDelay(pdMS_TO_TICKS(500)); + + if (!g_ncmConfig.enabled) { + ESP_LOGI(MAINTAG, "USB NCM: disabled by config (ncm=0), connection loop not started"); + vTaskDelete(NULL); + return; + } + + int maxCycles = g_ncmConfig.maxRetries; // 0=forever, else limit + int fixedDelay = g_ncmConfig.retryPeriod; // 0=backoff, else fixed seconds + ESP_LOGI(MAINTAG, "USB NCM: config retries=%d period=%ds", maxCycles, fixedDelay); + + vTaskDelay(pdMS_TO_TICKS(50)); // Let TinyUSB task initialise. + bool warmBoot = tud_mounted(); + int disconnectMs = warmBoot ? (USB_NCM_DISCONNECT_INIT_MS + USB_NCM_BACKOFF_STEP_MS) + : USB_NCM_DISCONNECT_INIT_MS; + int cycle = 0; + + while (!g_ncmDhcpLeased) + { + cycle++; + + // Check retry limit (0 = forever). + if (maxCycles > 0 && cycle > maxCycles) { + ESP_LOGW(MAINTAG, "USB NCM: retry limit reached (%d cycles), giving up", maxCycles); + break; + } + + ESP_LOGI(MAINTAG, "USB NCM: connection cycle %d%s (%s, disconnect=%d ms)", + cycle, maxCycles > 0 ? ("/" + std::to_string(maxCycles)).c_str() : "", + (cycle == 1 && warmBoot) ? "warm reboot" : "retry", disconnectMs); + + // --- Phase 1: Disconnect/reconnect --- + tud_disconnect(); + vTaskDelay(pdMS_TO_TICKS(disconnectMs)); + + tud_connect(); + vTaskDelay(pdMS_TO_TICKS(USB_NCM_CONNECT_SETTLE_MS)); + + // --- Phase 2: Wait for host to enumerate --- + bool mounted = false; + int waited = 0; + while (waited < USB_NCM_MOUNT_TIMEOUT_MS) + { + if (tud_mounted()) { + mounted = true; + ESP_LOGI(MAINTAG, "USB NCM: host enumerated (cycle %d, %d ms)", cycle, waited); break; } vTaskDelay(pdMS_TO_TICKS(USB_NCM_POLL_MS)); - dhcpWait += USB_NCM_POLL_MS; + waited += USB_NCM_POLL_MS; + } + + if (!mounted) { + ESP_LOGW(MAINTAG, "USB NCM: mount timeout (cycle %d)", cycle); + if (fixedDelay > 0) { + disconnectMs = fixedDelay * 1000; + } else { + disconnectMs += USB_NCM_BACKOFF_STEP_MS; + if (disconnectMs > USB_NCM_DISCONNECT_MAX_MS) + disconnectMs = USB_NCM_DISCONNECT_MAX_MS; + } + continue; + } + + // --- Phase 3: Link-state UP with retry --- + ESP_LOGI(MAINTAG, "USB NCM: waiting %d ms for host NCM driver...", USB_NCM_POST_MOUNT_MS); + vTaskDelay(pdMS_TO_TICKS(USB_NCM_POST_MOUNT_MS)); + + for (int linkAttempt = 1; linkAttempt <= USB_NCM_LINK_RETRIES && !g_ncmDhcpLeased; linkAttempt++) + { + if (linkAttempt > 1) { + tud_network_link_state(0, false); + vTaskDelay(pdMS_TO_TICKS(500)); + } + + tud_network_link_state(0, true); + ESP_LOGI(MAINTAG, "USB NCM: link UP (cycle %d, link attempt %d/%d)", + cycle, linkAttempt, USB_NCM_LINK_RETRIES); + + waited = 0; + while (waited < USB_NCM_DHCP_WAIT_MS && !g_ncmDhcpLeased) { + vTaskDelay(pdMS_TO_TICKS(USB_NCM_POLL_MS)); + waited += USB_NCM_POLL_MS; + } + + if (g_ncmDhcpLeased) { + ESP_LOGI(MAINTAG, "USB NCM: DHCP confirmed (cycle %d, link attempt %d, %d ms)", + cycle, linkAttempt, waited); + } + } + + if (!g_ncmDhcpLeased) { + ESP_LOGW(MAINTAG, "USB NCM: no DHCP after link-state retries (cycle %d), full reconnect...", cycle); + if (fixedDelay > 0) { + disconnectMs = fixedDelay * 1000; + } else { + disconnectMs += USB_NCM_BACKOFF_STEP_MS; + if (disconnectMs > USB_NCM_DISCONNECT_MAX_MS) + disconnectMs = USB_NCM_DISCONNECT_MAX_MS; + } } - // Allow a little extra time for the host DHCP client to finish even - // after the interface reports up. - vTaskDelay(pdMS_TO_TICKS(500)); } - ESP_LOGI(MAINTAG, "USB setup complete (CDC:%s NCM:%s mounted:%s)", - cdcOk ? "OK" : "FAIL", ncmOk ? "OK" : "FAIL", mounted ? "YES" : "NO"); - g_usbReady = true; + if (g_ncmDhcpLeased) { + ESP_LOGI(MAINTAG, "USB NCM: connection established after %d cycle(s)", cycle); + } vTaskDelete(NULL); } #endif // CONFIG_IF_USB_NCM_ENABLED @@ -736,6 +821,60 @@ void setupJSON(NVS &nvs, SDCard &sdcard, FSPI &fspi, cJSON *config) } } + // WiFi enable/disable from "esp32.core.wifi" — only effective if WiFi is compiled in. + { + cJSON *wifiEnable = cJSON_GetObjectItem(coreObj, "wifi_enable"); + if (cJSON_IsNumber(wifiEnable)) { +#if defined(CONFIG_IF_WIFI_ENABLED) + g_wifiEnabled = (wifiEnable->valueint != 0); +#else + g_wifiEnabled = false; // Not compiled in, always disabled. +#endif + } + ESP_LOGI(MAINTAG, "WiFi config: enabled=%d (compiled=%s)", + g_wifiEnabled, +#if defined(CONFIG_IF_WIFI_ENABLED) + "yes" +#else + "no" +#endif + ); + } + + // NCM connection parameters from "esp32.core" — only effective if NCM is compiled in. +#if defined(CONFIG_IF_USB_NCM_ENABLED) + { + cJSON *ncmEnable = cJSON_GetObjectItem(coreObj, "ncm"); + if (cJSON_IsNumber(ncmEnable)) + g_ncmConfig.enabled = (ncmEnable->valueint != 0); + + cJSON *ncmRetries = cJSON_GetObjectItem(coreObj, "ncmretries"); + if (cJSON_IsNumber(ncmRetries)) { + int v = ncmRetries->valueint; + if (v == 0 || (v >= 5 && v <= 1000)) + g_ncmConfig.maxRetries = v; + } + + cJSON *ncmPeriod = cJSON_GetObjectItem(coreObj, "ncmperiod"); + if (cJSON_IsNumber(ncmPeriod)) { + int v = ncmPeriod->valueint; + if (v >= 0 && v <= 120) + g_ncmConfig.retryPeriod = v; + } + + ESP_LOGI(MAINTAG, "NCM config: enabled=%d retries=%d period=%d", + g_ncmConfig.enabled, g_ncmConfig.maxRetries, g_ncmConfig.retryPeriod); + } +#else + // NCM not compiled in — if config says enabled, log a note. + { + cJSON *ncmEnable = cJSON_GetObjectItem(coreObj, "ncm"); + if (cJSON_IsNumber(ncmEnable) && ncmEnable->valueint != 0) { + ESP_LOGW(MAINTAG, "NCM config: enabled in config but NCM not compiled in — ignored"); + } + } +#endif + return; } @@ -1039,8 +1178,12 @@ extern "C" // Start WiFi (AP or Client mode). When USB NCM has already started // the webserver, the WiFi event handlers detect that server != NULL // and skip the redundant startWebserver() call. - ESP_LOGW(MAINTAG, "Starting WiFi (loopCount=%d).", loopCount); - wifi->run(); + if (g_wifiEnabled) { + ESP_LOGW(MAINTAG, "Starting WiFi (loopCount=%d).", loopCount); + wifi->run(); + } else { + ESP_LOGW(MAINTAG, "WiFi disabled by config (wifi_enable=0)."); + } #endif wifiStarted = true; } diff --git a/projects/tzpuPico/esp32/version.txt b/projects/tzpuPico/esp32/version.txt index a01a6e4..2db806a 100644 --- a/projects/tzpuPico/esp32/version.txt +++ b/projects/tzpuPico/esp32/version.txt @@ -1 +1 @@ -2.66 +2.74 diff --git a/projects/tzpuPico/esp32/webserver/filemanager.htm b/projects/tzpuPico/esp32/webserver/filemanager.htm index 14b94ed..4fde8ca 100644 --- a/projects/tzpuPico/esp32/webserver/filemanager.htm +++ b/projects/tzpuPico/esp32/webserver/filemanager.htm @@ -138,7 +138,11 @@
-

SD Card Directory

+

SD Card Directory + +

diff --git a/projects/tzpuPico/esp32/webserver/js/configgui.js b/projects/tzpuPico/esp32/webserver/js/configgui.js index 1b38a2e..0629b4e 100644 --- a/projects/tzpuPico/esp32/webserver/js/configgui.js +++ b/projects/tzpuPico/esp32/webserver/js/configgui.js @@ -401,6 +401,13 @@ var ConfigGUI = (function($) { $tbody.html('Empty directory'); return; } + // Sort: ".." first, then directories alphabetically, then files alphabetically. + arr.sort(function(a, b) { + if (a.name === '..') return -1; + if (b.name === '..') return 1; + if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1; + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + }); $.each(arr, function(i, entry) { var $tr = $(''); var $td = $(''); @@ -471,7 +478,7 @@ var ConfigGUI = (function($) { // Core Settings Renderer // ----------------------------------------------------------------------- - function renderCoreSection(containerId, coreObj, title) { + function renderCoreSection(containerId, coreObj, title, isPartition) { var id = uid('core'); var html = '
'; html += '
'; @@ -506,6 +513,18 @@ var ConfigGUI = (function($) { html += '50 - 166 MHz (warning above 133 MHz)'; html += '
'; + // z80refresh — only shown for partition cores (not global RP2350 core). + if (isPartition) { + var refreshChk = coreObj.z80refresh ? ' checked' : ''; + html += '
'; + html += ''; + html += '
'; + html += '
Enable
'; + html += '
'; + html += 'Insert DRAM refresh cycles when executing from virtual ROM/RAM (needed when mixing virtual ROM with physical DRAM)'; + html += '
'; + } + html += ''; html += '
'; @@ -516,13 +535,17 @@ var ConfigGUI = (function($) { } // Collect core settings from the form back into an object. - function collectCore(containerId) { + function collectCore(containerId, isPartition) { var $el = $('#' + containerId); - return { + var obj = { voltage: parseFloat($el.find('[data-field="voltage"]').val()) || 1.10, cpufreq: mhzToFreq($el.find('[data-field="cpufreq"]').val()) || 240000000, psramfreq: mhzToFreq($el.find('[data-field="psramfreq"]').val()) || 133000000 }; + if (isPartition) { + obj.z80refresh = $el.find('[data-field="z80refresh"]').is(':checked') ? 1 : 0; + } + return obj; } // ----------------------------------------------------------------------- @@ -988,14 +1011,23 @@ var ConfigGUI = (function($) { collectValue: function($td) { return $td.find('[data-field="param-value"]').val() || ''; } + }, + ip: { + label: 'IP Address', + renderValue: function(val) { + return ''; + }, + collectValue: function($td) { + return $td.find('[data-field="param-value"]').val() || ''; + } } - // Future parameter types can be added here, e.g.: - // integer: { label: 'Integer', renderValue: function(val) { ... }, collectValue: function($td) { ... } } - // string: { label: 'String', renderValue: function(val) { ... }, collectValue: function($td) { ... } } }; // List of type keys for the dropdown, in display order. - var paramTypeOrder = ['file']; + var paramTypeOrder = ['file', 'ip']; // Detect which param type a config object uses (by checking which known key is present). function detectParamType(paramObj) { @@ -1203,6 +1235,20 @@ var ConfigGUI = (function($) { $(this).closest('[data-driver-idx]').remove(); }); + // Bind add/remove/browse for driver-level ROM entries. + $container.on('click', '[data-action="add-drv-rom"]', function() { + var $tbody = $(this).closest('[data-section="drv-roms"]').find('table[data-section="drv-rom"] tbody'); + $tbody.append(renderDrvRomRow({ enable: 0, file: '' })); + }); + $container.on('click', '[data-action="remove-drv-rom"]', function() { + if (confirm('Remove this system ROM entry?')) + $(this).closest('tr').remove(); + }); + $container.on('click', '[data-action="browse-drv-rom"]', function() { + var $input = $(this).closest('td').find('[data-field="file"]'); + openFileBrowser($input); + }); + // Bind remove-interface handler $container.on('click', '[data-action="remove-interface"]', function(e) { e.stopPropagation(); @@ -1349,6 +1395,18 @@ var ConfigGUI = (function($) { }); } + // Render a single driver-level ROM row (simple: enable + file, no loadaddr). + function renderDrvRomRow(rom) { + var chk = rom.enable ? ' checked' : ''; + var html = ''; + html += ''; + html += '
'; + html += '
'; + html += ''; + html += ''; + return html; + } + function renderDriver(driver, drvIdx) { var drvId = uid('drv'); var chk = driver.enable ? ' checked' : ''; @@ -1383,6 +1441,25 @@ var ConfigGUI = (function($) { html += ''; html += '
'; + // Driver-level ROMs — system ROMs that overwrite the host's ROMs when enabled. + // The driver knows the load layout so no loadaddr is needed, just file + enable. + html += '
'; + html += '
System ROMs
'; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + if (driver.rom && driver.rom.length > 0) { + for (var dr = 0; dr < driver.rom.length; dr++) { + html += renderDrvRomRow(driver.rom[dr]); + } + } + html += '
EnFile
'; + html += ''; + html += '
'; + // Interfaces - always show section with Add button html += '
'; html += '
Interfaces
'; @@ -1415,6 +1492,19 @@ var ConfigGUI = (function($) { type: $drv.find('[data-field="drv-type"]').first().val() }; + // Collect driver-level ROMs + var drvRoms = []; + $drv.find('table[data-section="drv-rom"] tbody tr').each(function() { + var $tr = $(this); + drvRoms.push({ + enable: $tr.find('[data-field="enable"]').is(':checked') ? 1 : 0, + file: $tr.find('[data-field="file"]').val() || '' + }); + }); + if (drvRoms.length > 0) { + driver.rom = drvRoms; + } + // Collect interfaces var $ifs = $drv.find('[data-if-idx]'); if ($ifs.length > 0) { @@ -1460,6 +1550,46 @@ var ConfigGUI = (function($) { html += 'Operating mode'; html += '
'; + // WiFi settings + html += '
'; + html += '
Network Interfaces
'; + + var wifiChk = (espCore.wifi_enable === undefined || espCore.wifi_enable) ? ' checked' : ''; + html += '
'; + html += ''; + html += '
'; + html += '
Enable
'; + html += '
'; + html += 'Enable/disable WiFi (requires reboot). Ignored if WiFi not compiled in.'; + html += '
'; + + // NCM (USB Network) settings + + var ncmChk = (espCore.ncm === undefined || espCore.ncm) ? ' checked' : ''; + html += '
'; + html += ''; + html += '
'; + html += '
Enable
'; + html += '
'; + html += 'Enable/disable USB NCM network (requires reboot)'; + html += '
'; + + html += '
'; + html += ''; + html += '
'; + html += ''; + html += '
'; + html += '0 = retry forever, 5-1000 = max attempts'; + html += '
'; + + html += '
'; + html += ''; + html += '
'; + html += ''; + html += '
'; + html += '0 = increasing backoff (1s per retry, max 60s), 1-120 = fixed seconds'; + html += '
'; + html += ''; html += ''; @@ -1469,8 +1599,12 @@ var ConfigGUI = (function($) { function collectESP32Core(containerId) { var $el = $('#' + containerId); return { - device: $el.find('[data-field="device"]').val() || 'Z80', - mode: parseInt($el.find('[data-field="mode"]').val(), 10) || 0 + device: $el.find('[data-field="device"]').val() || 'Z80', + mode: parseInt($el.find('[data-field="mode"]').val(), 10) || 0, + wifi_enable: $el.find('[data-field="wifi_enable"]').is(':checked') ? 1 : 0, + ncm: $el.find('[data-field="ncm"]').is(':checked') ? 1 : 0, + ncmretries: parseInt($el.find('[data-field="ncmretries"]').val(), 10) || 0, + ncmperiod: parseInt($el.find('[data-field="ncmperiod"]').val(), 10) || 0 }; } @@ -1587,18 +1721,18 @@ var ConfigGUI = (function($) { var z80 = rp.z80 || []; // RP2350 Global Core - renderCoreSection('cfg-rp2350-core', rp.core || {}, 'RP2350 Global Core Settings'); + renderCoreSection('cfg-rp2350-core', rp.core || {}, 'RP2350 Global Core Settings', false); // Partition 1 (z80[1]) var p1 = z80[1] || {}; - renderCoreSection('cfg-p1-core', p1.core || {}, 'Partition 1 Core Settings'); + renderCoreSection('cfg-p1-core', p1.core || {}, 'Partition 1 Core Settings', true); renderMemorySection('cfg-p1-memory', p1.memory || []); renderIOSection('cfg-p1-io', p1.io || []); renderDriversSection('cfg-p1-drivers', p1.drivers || []); // Partition 2 (z80[2]) var p2 = z80[2] || {}; - renderCoreSection('cfg-p2-core', p2.core || {}, 'Partition 2 Core Settings'); + renderCoreSection('cfg-p2-core', p2.core || {}, 'Partition 2 Core Settings', true); renderMemorySection('cfg-p2-memory', p2.memory || []); renderIOSection('cfg-p2-io', p2.io || []); renderDriversSection('cfg-p2-drivers', p2.drivers || []); @@ -1619,20 +1753,20 @@ var ConfigGUI = (function($) { function collectConfig() { var cfg = { rp2350: { - core: collectCore('cfg-rp2350-core'), + core: collectCore('cfg-rp2350-core', false), z80: [ // z80[0] = Bootloader (currently empty) {}, // z80[1] = Partition 1 { - core: collectCore('cfg-p1-core'), + core: collectCore('cfg-p1-core', true), memory: collectMemory('cfg-p1-memory'), io: collectIO('cfg-p1-io'), drivers: collectDrivers('cfg-p1-drivers') }, // z80[2] = Partition 2 { - core: collectCore('cfg-p2-core'), + core: collectCore('cfg-p2-core', true), memory: collectMemory('cfg-p2-memory'), io: collectIO('cfg-p2-io'), drivers: collectDrivers('cfg-p2-drivers') @@ -1756,6 +1890,8 @@ var ConfigGUI = (function($) { } showMsg(msg, 'alert-success'); $('#cfgSaveBtn').prop('disabled', false); + // Reload the config from the device to reflect the saved state. + setTimeout(function() { fetchConfig(); }, 1000); }, error: function(jqXHR, textStatus, errorThrown) { var msg = jqXHR.responseText || 'Failed to save configuration: ' + (errorThrown || textStatus); diff --git a/projects/tzpuPico/esp32/webserver/js/filemanager.js b/projects/tzpuPico/esp32/webserver/js/filemanager.js index f23634d..d895cff 100644 --- a/projects/tzpuPico/esp32/webserver/js/filemanager.js +++ b/projects/tzpuPico/esp32/webserver/js/filemanager.js @@ -1,5 +1,20 @@ var lastStatus = 0; +// Backup the entire SD card as a tar download. +function backupSD() { + if (!confirm('Download the entire SD card as a tar archive?\n\nThis may take several minutes depending on the SD card size and connection speed.')) + return; + + // Create a temporary hidden link to trigger the download. + var a = document.createElement('a'); + a.href = '/data/backup'; + a.download = 'picoZ80_backup.tar'; + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); +} + // Confirm delete with "Don't ask again" checkbox. // Uses localStorage to persist the preference across sessions. // To re-enable: localStorage.removeItem('skipDeleteConfirm') in browser console. @@ -126,6 +141,11 @@ function uploadFile() { var fileSize = file.size; var xhttp = new XMLHttpRequest(); + // Detect if the file is an archive that the server will unpack. + var fileName = file.name.toLowerCase(); + var isArchive = fileName.endsWith('.tar') || fileName.endsWith('.gz') || + fileName.endsWith('.tgz') || fileName.endsWith('.tar.gz'); + // Track upload progress. xhttp.upload.addEventListener("progress", function(e) { if (e.lengthComputable && progressBar && progressText) { @@ -143,14 +163,24 @@ function uploadFile() { } }); + // When upload transfer completes, show "Unpacking..." for archives while + // waiting for the server to finish processing and send its response. + xhttp.upload.addEventListener("load", function() { + if (isArchive && progressBar && progressText) { + progressBar.style.width = "100%"; + progressBar.style.background = "#f0ad4e"; // amber/orange + progressText.textContent = "Unpacking archive on device... please wait"; + } + }); + xhttp.onreadystatechange = function() { if (xhttp.readyState == 4) { if (xhttp.status == 200) { - if (progressBar) progressBar.style.width = "100%"; - if (progressText) progressText.textContent = "Upload complete!"; + if (progressBar) { progressBar.style.width = "100%"; progressBar.style.background = "#5cb85c"; } + if (progressText) progressText.textContent = isArchive ? "Unpacked successfully!" : "Upload complete!"; setTimeout(function() { location.reload(); }, 1000); } else if (xhttp.status == 0) { @@ -251,3 +281,88 @@ function mkdir() { alert('Failed to create directory'); }); } + +// --------------------------------------------------------------------------- +// Client-side column sorting for the file manager table. +// Clicking a column header toggles ascending/descending sort. +// The first two rows (up-level link + upload controls) are kept at the top; +// only the file/directory data rows are sorted. +// --------------------------------------------------------------------------- +(function() { + // Track current sort state. + var currentCol = -1; + var ascending = true; + + document.addEventListener('click', function(e) { + var th = e.target.closest('th[data-sort-col]'); + if (!th) return; + + var col = parseInt(th.getAttribute('data-sort-col'), 10); + var sortType = th.getAttribute('data-sort-type') || 'text'; + var table = th.closest('table'); + if (!table) return; + + var tbody = table.querySelector('tbody'); + if (!tbody) return; + + // Toggle direction if clicking the same column, otherwise ascending. + if (col === currentCol) { + ascending = !ascending; + } else { + currentCol = col; + ascending = true; + } + + // Collect all rows. The first rows may be [up level] and upload controls — + // identify data rows by having a form with cmd=ren (the rename form). + var allRows = Array.prototype.slice.call(tbody.querySelectorAll('tr')); + var fixedRows = []; + var dataRows = []; + allRows.forEach(function(row) { + if (row.querySelector('input[name="oldname"]')) { + dataRows.push(row); + } else { + fixedRows.push(row); + } + }); + + // Sort data rows. + dataRows.sort(function(a, b) { + var cellA = a.cells[col]; + var cellB = b.cells[col]; + if (!cellA || !cellB) return 0; + + var valA, valB; + if (sortType === 'num') { + // Extract numeric value (from size column text content). + valA = parseFloat(cellA.textContent.replace(/[^0-9.-]/g, '')) || 0; + valB = parseFloat(cellB.textContent.replace(/[^0-9.-]/g, '')) || 0; + } else { + // Text sort: use the input value for column 0 (name), textContent otherwise. + var inputA = cellA.querySelector('input[name="name"]'); + var inputB = cellB.querySelector('input[name="name"]'); + valA = (inputA ? inputA.value : cellA.textContent).toLowerCase(); + valB = (inputB ? inputB.value : cellB.textContent).toLowerCase(); + } + + if (valA < valB) return ascending ? -1 : 1; + if (valA > valB) return ascending ? 1 : -1; + return 0; + }); + + // Re-append rows: fixed rows first, then sorted data rows. + fixedRows.forEach(function(row) { tbody.appendChild(row); }); + dataRows.forEach(function(row) { tbody.appendChild(row); }); + + // Update sort indicator icons. + table.querySelectorAll('th[data-sort-col] .fa').forEach(function(icon) { + icon.className = 'fa fa-sort'; + icon.style.color = '#999'; + }); + var icon = th.querySelector('.fa'); + if (icon) { + icon.className = ascending ? 'fa fa-sort-asc' : 'fa fa-sort-desc'; + icon.style.color = '#333'; + } + }); +})(); diff --git a/projects/tzpuPico/esp32/webserver/webfs_version.txt b/projects/tzpuPico/esp32/webserver/webfs_version.txt index 6e272ad..995e96d 100644 --- a/projects/tzpuPico/esp32/webserver/webfs_version.txt +++ b/projects/tzpuPico/esp32/webserver/webfs_version.txt @@ -1 +1 @@ -2.49 +2.58 diff --git a/projects/tzpuPico/src/Z80CPU.c b/projects/tzpuPico/src/Z80CPU.c index bae0d5b..993e67f 100644 --- a/projects/tzpuPico/src/Z80CPU.c +++ b/projects/tzpuPico/src/Z80CPU.c @@ -105,8 +105,8 @@ volatile uint8_t g_dbgForceIOWait; // IRQ 0 - SM0 - ADDRESS LOAD // IRQ 1 - SM1 - DATA LOAD static uint sm_addr = PIO_SM_0; // 4 words -static uint sm_data = PIO_SM_1; // 6 words - // 10 total words +static uint sm_data = PIO_SM_1; // 7 words + // 11 total words // PIO 1 state machines. // IRQ 0 - SM0 - CYCLE START // IRQ 5 - SM3 - REFRESH @@ -632,6 +632,45 @@ bool Z80CPU_configDriversFromJSON(t_Z80CPU *cpu, t_FlashAppConfigHeader *appConf continue; } + ///////////////////////////// + // Driver-level "rom" array + ///////////////////////////// + + // Optional ROM files at the driver level (not interface level). + // These are system ROMs where the driver knows the load layout (no loadaddr needed). + cJSON *drvROMArr = cJSON_GetObjectItem(driver, "rom"); + if (cJSON_IsArray(drvROMArr)) + { + size_t drvROMCnt = cJSON_GetArraySize(drvROMArr); + if (drvROMCnt > 0) + { + if (!cpu->_drivers.driver[validDrivers].romConfig) + cpu->_drivers.driver[validDrivers].romConfig = (t_drvROMConfig *) calloc(drvROMCnt, sizeof(t_drvROMConfig)); + if (cpu->_drivers.driver[validDrivers].romConfig) + { + int validDrvROMs = 0; + for (size_t ri = 0; ri < drvROMCnt; ri++) + { + cJSON *romItem = cJSON_GetArrayItem(drvROMArr, ri); + if (!cJSON_IsObject(romItem)) + continue; + cJSON *romEnable = cJSON_GetObjectItem(romItem, "enable"); + if (!cJSON_IsNumber(romEnable) || romEnable->valueint != 1) + continue; + cJSON *romFile = cJSON_GetObjectItem(romItem, "file"); + if (!cJSON_IsString(romFile) || strlen(romFile->valuestring) == 0) + continue; + cpu->_drivers.driver[validDrivers].romConfig[validDrvROMs].romFile = strdup(romFile->valuestring); + cpu->_drivers.driver[validDrivers].romConfig[validDrvROMs].romAddrCount = 0; + cpu->_drivers.driver[validDrivers].romConfig[validDrvROMs].romAddr = NULL; + validDrvROMs++; + } + cpu->_drivers.driver[validDrivers].romCount = validDrvROMs; + debugf("Z80 Drivers ROM: Driver ROM Maps(%d)\r\n", validDrvROMs); + } + } + } + ////////////// // "if" array ////////////// @@ -981,14 +1020,24 @@ bool Z80CPU_configDriversFromJSON(t_Z80CPU *cpu, t_FlashAppConfigHeader *appConf continue; } - // Extract fields + // Extract known key/value pairs. Each param entry has one key + // that identifies the parameter type ("file", "ip", etc.). cJSON *file = cJSON_GetObjectItem(paramObj, "file"); + cJSON *ip = cJSON_GetObjectItem(paramObj, "ip"); - if (!cJSON_IsString(file) || strlen(file->valuestring) == 0) + bool hasValue = false; + if (cJSON_IsString(file) && strlen(file->valuestring) > 0) { - continue; + cpu->_drivers.driver[validDrivers].ifConfig[validDriverIF].ifParam[validParams].file = strdup(file->valuestring); + hasValue = true; } - cpu->_drivers.driver[validDrivers].ifConfig[validDriverIF].ifParam[validParams].file = strdup(file->valuestring); + if (cJSON_IsString(ip) && strlen(ip->valuestring) > 0) + { + cpu->_drivers.driver[validDrivers].ifConfig[validDriverIF].ifParam[validParams].ip = strdup(ip->valuestring); + hasValue = true; + } + if (!hasValue) + continue; validParams++; } cpu->_drivers.driver[validDrivers].ifConfig[validDriverIF].ifParamCount = validParams; @@ -1626,6 +1675,79 @@ bool Z80CPU_parseJSONStore(t_Z80CPU *cpu, cJSON *configRoot, uint8_t cfgApp, cha } debugf("Z80 Drivers: enable(%d) name(%s) physical(%s)\r\n", enableDriver->valueint, driverName->valuestring, driverIsPhysical->valuestring); + ///////////////////////////// + // Driver-level "rom" array + ///////////////////////////// + + // Driver-level ROM files (no loadaddr — the driver knows the layout). + // Parse and store to flash so Z80CPU_ReadROM can find them at startup. + cJSON *drvROMDrv = cJSON_GetObjectItem(driver, "rom"); + if (cJSON_IsArray(drvROMDrv)) + { + size_t drvROMDrvCount = cJSON_GetArraySize(drvROMDrv); + for (size_t ri = 0; ri < drvROMDrvCount; ri++) + { + cJSON *romItem = cJSON_GetArrayItem(drvROMDrv, ri); + if (!cJSON_IsObject(romItem)) + continue; + cJSON *romEnable = cJSON_GetObjectItem(romItem, "enable"); + if (cJSON_IsNumber(romEnable) && romEnable->valueint != 1) + continue; + cJSON *romFile = cJSON_GetObjectItem(romItem, "file"); + if (!cJSON_IsString(romFile) || strlen(romFile->valuestring) == 0) + continue; + + debugf("Z80 Drivers ROM: file(%s)\r\n", romFile->valuestring); + + // Store to flash (same pattern as interface ROMs). + if (appConfig->s.romCount >= (FLASH_MAX_ROM_IMAGES - 1)) + { + debugf("Error: No free slots in ROM Header for file:%s\r\n", romFile->valuestring); + continue; + } + int found = -1; + for (int idx = 0; idx < appConfig->s.romCount; idx++) + { + if (strcmp(appConfig->s.ROMS[idx].filename, romFile->valuestring) == 0) + found = idx; + } + if (found >= 0) + { + debugf("Warning: ROM (file=%s) already stored in slot %d, skipping.\r\n", romFile->valuestring, found); + continue; + } + + memset((uint8_t *) romBuffer, 0, FLASH_MAX_ROM_IMAGE_SIZE); + watchdog_update(); + debugf("Reading driver ROM file:%s\r\n", romFile->valuestring); + int fileSize = ESP_readFile(romFile->valuestring, NULL, NULL, NULL, &romBuffer, FLASH_MAX_ROM_IMAGE_SIZE, false, 0); + watchdog_update(); + if (fileSize <= 0) + { + debugf("Error: ROM file '%s' not found or empty\r\n", romFile->valuestring); + continue; + } + if ((romNextPos + fileSize) > (FLASH_APP_START_CONFIG_POS + ((cfgApp - 1) * FLASH_APP_CONFIG_SIZE) + FLASH_APP_CONFIG_SIZE)) + { + debugf("Error: ROM Image (%s, %dbytes) exceeds available FlashRAM storage\r\n", romFile->valuestring, fileSize); + continue; + } + uint32_t flashSize = ((fileSize % FLASH_SECTOR_SIZE) == 0) ? fileSize : ((fileSize / FLASH_SECTOR_SIZE) + 1) * FLASH_SECTOR_SIZE; + if ((romNextPos + flashSize) > (FLASH_APP_START_CONFIG_POS + ((cfgApp - 1) * FLASH_APP_CONFIG_SIZE) + FLASH_APP_CONFIG_SIZE)) + { + debugf("Error: ROM Image (%s) rounded up to sector exceeds FlashRAM\r\n", romFile->valuestring); + continue; + } + updateFlashBytes(romNextPos, (uint8_t *) romBuffer, flashSize); + appConfig->s.ROMS[appConfig->s.romCount].size = fileSize; + appConfig->s.ROMS[appConfig->s.romCount].addr = romNextPos; + strcpy(appConfig->s.ROMS[appConfig->s.romCount].filename, romFile->valuestring); + romNextPos += flashSize; + appConfig->s.romCount++; + debugf("Z80 Drivers ROM: stored %d bytes to flash slot %d\r\n", fileSize, appConfig->s.romCount - 1); + } + } + //////////////// // "if" array //////////////// @@ -2014,16 +2136,24 @@ bool Z80CPU_parseJSONStore(t_Z80CPU *cpu, cJSON *configRoot, uint8_t cfgApp, cha continue; } - // Extract fields + // Extract known key/value pairs. cJSON *file = cJSON_GetObjectItem(paramObj, "file"); + cJSON *ip = cJSON_GetObjectItem(paramObj, "ip"); - if (!cJSON_IsString(file) || strlen(file->valuestring) == 0) + if (cJSON_IsString(file) && strlen(file->valuestring) > 0) + { + debugf("Z80 Drivers IF Params: file(%s)\r\n", file->valuestring); + } + else if (cJSON_IsString(ip) && strlen(ip->valuestring) > 0) + { + debugf("Z80 Drivers IF Params: ip(%s)\r\n", ip->valuestring); + } + else { debugf("Error: Virtual driver \"param\" item %zu, driver %zu invalid, skipping\r\n", i7, drvConfig); result = false; continue; } - debugf("Z80 Drivers IF Params: file(%s)\r\n", file->valuestring); } } @@ -2228,7 +2358,11 @@ int Z80CPU_init_pio(void) } pio_sm_set_enabled(pio_0, sm_addr, true); - // Data Bus - Setup data on the bus, tri-state on BUSACK. + // Data Bus - Setup data on the bus, tristate after /WR completes. + // After driving data, the SM waits for /WR low then high, ensuring the + // data bus is held for the entire write cycle (including wait states), + // then tristates. This prevents stale data persisting on the physical + // bus in virtual mode where no subsequent bus cycle would clear it. offset_data = pio_add_program(pio_0, &z80_data_program); pio_sm_config c_data = z80_data_program_get_default_config(offset_data); sm_config_set_out_pins(&c_data, Z80_PIN_DATA_0, 8); @@ -2236,6 +2370,13 @@ int Z80CPU_init_pio(void) sm_config_set_out_shift(&c_data, true, true, 24); // Shift right, auto pull 24 bits (8 final direction, 8 data, 8 start direction). pio_sm_set_consecutive_pindirs(pio_0, sm_data, Z80_PIN_DATA_0, 8, false); // Set as input initially. pio_sm_set_consecutive_pindirs(pio_0, sm_data, Z80_PIN_BUSACK, 1, false); + // Enable internal pull-ups on D0-D7. A real Z80 has weak internal pull-ups + // on its data bus; when no device is driving, the bus drifts to 0xFF. + // Without pull-ups the RP2350 GPIOs are true high-Z — bus capacitance holds + // the last driven value indefinitely, causing false device detection in + // virtual mode (e.g. IPL FDC probe writes 0xA5, reads it back after 80µs). + for (uint i = Z80_PIN_DATA_0; i < Z80_PIN_DATA_0 + 8; i++) + gpio_pull_up(i); sm_config_set_clkdiv(&c_data, 1.0f); pio_sm_clear_fifos(pio_0, sm_data); pio_sm_restart(pio_0, sm_data); @@ -3178,19 +3319,22 @@ uint8_t __func_in_RAM(Z80CPU_readIntAck)(void *context, uint16_t address) switch (cpu->_Z80.im) { - case 0: - debugf("INT IM0 not implemented.\r\n"); - break; + // Interrupt Mode 0 + case 0: + debugf("INT IM0 not implemented.\r\n"); + break; - case 2: - debugf("ReadInt:%04x, mode:%02x\r\n", address, cpu->_Z80.im); - // Get the vector from the bus, used with I register to form the address of the ISR. - vector = Z80CPU_fetchPhysicalIntVector(cpu, address); - break; + // Interrupt Mode 2 - Fetch the address to branch to from the bus. + case 2: + // Get the vector from the bus, used with I register to form the address of the ISR. + //debugf("ReadInt:%04x, mode:%02x\r\n", address, cpu->_Z80.im); + vector = Z80CPU_fetchPhysicalIntVector(cpu, address); + break; - case 1: - default: - break; + // Interrupt Mode 1 - Basic branch to 0x0038 + case 1: + default: + break; } return (vector); @@ -3223,14 +3367,14 @@ void __func_in_RAM(Z80CPU_ldia)(void *context) { // Locals. Z_UNUSED(context) - debugf("LDIA\r\n"); + //debugf("LDIA\r\n"); } void __func_in_RAM(Z80CPU_ldra)(void *context) { // Locals. Z_UNUSED(context) - debugf("LDRA\r\n"); + //debugf("LDRA\r\n"); } // Z80 RETI instruction, ie. return from interrupt. diff --git a/projects/tzpuPico/src/dbgsh.c b/projects/tzpuPico/src/dbgsh.c index e01155b..95daa64 100644 --- a/projects/tzpuPico/src/dbgsh.c +++ b/projects/tzpuPico/src/dbgsh.c @@ -1434,7 +1434,7 @@ static void cmdSaveFile(t_Z80CPU *cpu, int argc, char **argv) if (argc < 5) { shPuts("Usage: save \r\n" - " p = physical read, pf = physical fetch (M1), v = virtual PSRAM bank 0\r\n" + " p = physical read, pf = physical fetch (M1), v = virtual PSRAM (full addr, e.g. 0x27D000 = bank 39)\r\n" " filename relative to /sdcard/ on ESP32\r\n"); return; } @@ -1490,7 +1490,8 @@ static void cmdSaveFile(t_Z80CPU *cpu, int argc, char **argv) } else { - scratch[i] = cpu->_z80PSRAM->RAM[z80Addr]; + // Use full 32-bit address for virtual PSRAM access (supports bank > 0). + scratch[i] = cpu->_z80PSRAM->RAM[(addr + i) % sizeof(cpu->_z80PSRAM->RAM)]; } } diff --git a/projects/tzpuPico/src/drivers/Sharp/Celestite.c b/projects/tzpuPico/src/drivers/Sharp/Celestite.c index 85bed6a..bb0c247 100644 --- a/projects/tzpuPico/src/drivers/Sharp/Celestite.c +++ b/projects/tzpuPico/src/drivers/Sharp/Celestite.c @@ -114,6 +114,17 @@ static void celestiteProcessSocketCmd(uint8_t sockNum) *sr = SOCK_CLOSED; break; } + // Reset RX state for the new socket (clear stale data from previous connection). + celestiteCtrl->w5100Mem[sockBase + Sn_RX_RSR0] = 0; + celestiteCtrl->w5100Mem[sockBase + Sn_RX_RSR0 + 1] = 0; + celestiteCtrl->w5100Mem[sockBase + Sn_RX_RD0] = 0; + celestiteCtrl->w5100Mem[sockBase + Sn_RX_RD0 + 1] = 0; + celestiteCtrl->netRecvPending[sockNum] = false; + // Discard any stale RECV result from previous connection on this socket. + if (celestiteCtrl->netRecvReady && celestiteCtrl->netRecvSock == sockNum) + { + celestiteCtrl->netRecvReady = false; + } // Queue ESP32 socket creation (fire-and-forget — local state already set). // Don't set netSockPending so that the next operation (CONNECT/LISTEN) // can properly track its response. @@ -163,6 +174,16 @@ static void celestiteProcessSocketCmd(uint8_t sockNum) *sr = SOCK_CLOSED; celestiteCtrl->netSockPending[sockNum] = false; celestiteCtrl->netRecvPending[sockNum] = false; + // Discard any in-flight RECV result for this socket. + // Without this, a stale RECV from a large TCP response (>2KB) survives + // through CLOSE/OPEN and corrupts the next connection's RX buffer. + if (celestiteCtrl->netRecvReady && celestiteCtrl->netRecvSock == sockNum) + { + celestiteCtrl->netRecvReady = false; + } + // Clear RSR so stale data doesn't affect the next OPEN on this socket. + celestiteCtrl->w5100Mem[sockBase + Sn_RX_RSR0] = 0; + celestiteCtrl->w5100Mem[sockBase + Sn_RX_RSR0 + 1] = 0; break; case Sn_CR_SEND: @@ -220,7 +241,7 @@ static void celestiteW5100Reset(void) memset(celestiteCtrl->w5100Mem, 0, W5100_MEM_SIZE); // Set defaults per W5100 datasheet. - celestiteCtrl->w5100Mem[W5100_MR] = W5100_MR_IND; // IDM mode enabled after reset. + celestiteCtrl->w5100Mem[W5100_MR] = W5100_MR_IND | W5100_MR_AI; // IDM mode + auto-increment. celestiteCtrl->w5100Mem[W5100_RTR0] = 0x07; // Default retry time = 200ms (0x07D0 × 100µs). celestiteCtrl->w5100Mem[W5100_RTR0 + 1] = 0xD0; celestiteCtrl->w5100Mem[W5100_RCR] = 0x08; // Default retry count = 8. @@ -288,64 +309,97 @@ uint8_t Celestite_Init(t_Z80CPU *cpu, t_drvIFConfig *config) celestiteCtrl->romWriteUnprotected = false; memset(celestiteCtrl->ufm, 0xFF, CELESTITE_UFM_SIZE); - // Integrated MZ-1R12 CMOS RAM — enabled if params[0].file is set. - // params[0].file = SD card backing file for MZ-1R12 (e.g., "ram/CELESTITE_R12.ram"). - // params[1].file = SD card backing file for MZ-1R37 (e.g., "ram/CELESTITE_R37.ram"). + // Initialise optional features to defaults. celestiteCtrl->mz1r12Ram = NULL; celestiteCtrl->mz1r12Size = 0; celestiteCtrl->mz1r12Addr = 0; celestiteCtrl->mz1r12FileName = NULL; celestiteCtrl->mz1r12WritePending = false; celestiteCtrl->mz1r12NextReqId = 1; - celestiteCtrl->mz1r37Ram = NULL; celestiteCtrl->mz1r37AddrLatch = 0; celestiteCtrl->mz1r37FileName = NULL; + celestiteCtrl->mz1r37WritePending = false; + celestiteCtrl->netSrvIP[0] = 0; + celestiteCtrl->netSrvIP[1] = 0; + celestiteCtrl->netSrvIP[2] = 0; + celestiteCtrl->netSrvIP[3] = 0; + celestiteCtrl->netSrvPort = 6800; + celestiteCtrl->cfgIndex = 0; - // Parse params for memory backing files. - if (config->ifParamCount >= 1 && config->ifParam[0].file && config->ifParam[0].file[0] != '\0') + // Parse params by key type — order and count don't matter. + // "file" params: first = MZ-1R12 backing, second = MZ-1R37 backing. + // "ip" param: NET file server address as "a.b.c.d" or "a.b.c.d:port". + int fileIdx = 0; + for (int p = 0; p < config->ifParamCount; p++) { - // Allocate MZ-1R12 CMOS RAM (32KB, doubler to 64KB via unlock D1h+12h). - // Allocate 64KB up front so doubling doesn't need realloc. - celestiteCtrl->mz1r12Ram = (uint8_t *) calloc(1, CELESTITE_R12_DBL); - celestiteCtrl->mz1r12Size = CELESTITE_R12_SIZE; // 32KB initially. - if (celestiteCtrl->mz1r12Ram) + if (config->ifParam[p].ip && config->ifParam[p].ip[0] != '\0') { - celestiteCtrl->mz1r12FileName = strdup(config->ifParam[0].file); - debugf("Celestite: MZ-1R12 32KB enabled, file=%s\r\n", celestiteCtrl->mz1r12FileName); - - // Queue load from SD card. - t_CoreMsg msg; - memset(&msg, 0, sizeof(msg)); - msg.type = MSG_LOAD_RAMFILE; - msg.context = celestiteCtrl; - msg.requestId = 0xFC; // MZ-1R12 load sentinel. - strncpy(msg.fileOp.filename, celestiteCtrl->mz1r12FileName, MAX_IC_FILENAME_LEN - 1); - msg.fileOp.buffer = celestiteCtrl->mz1r12Ram; - msg.fileOp.size = CELESTITE_R12_SIZE; - queue_try_add(&cpu->requestQueue, &msg); + unsigned int ip0, ip1, ip2, ip3, port; + int matched = sscanf(config->ifParam[p].ip, "%u.%u.%u.%u:%u", &ip0, &ip1, &ip2, &ip3, &port); + if (matched >= 4) + { + celestiteCtrl->netSrvIP[0] = (uint8_t) ip0; + celestiteCtrl->netSrvIP[1] = (uint8_t) ip1; + celestiteCtrl->netSrvIP[2] = (uint8_t) ip2; + celestiteCtrl->netSrvIP[3] = (uint8_t) ip3; + if (matched >= 5) + celestiteCtrl->netSrvPort = (uint16_t) port; + } + debugf("Celestite: NET server=%u.%u.%u.%u:%u\r\n", + celestiteCtrl->netSrvIP[0], celestiteCtrl->netSrvIP[1], + celestiteCtrl->netSrvIP[2], celestiteCtrl->netSrvIP[3], + celestiteCtrl->netSrvPort); } - } - - if (config->ifParamCount >= 2 && config->ifParam[1].file && config->ifParam[1].file[0] != '\0') - { - // Allocate MZ-1R37 EMM (640KB). - celestiteCtrl->mz1r37Ram = (uint8_t *) calloc(1, CELESTITE_R37_SIZE); - if (celestiteCtrl->mz1r37Ram) + else if (config->ifParam[p].file && config->ifParam[p].file[0] != '\0') { - celestiteCtrl->mz1r37FileName = strdup(config->ifParam[1].file); - debugf("Celestite: MZ-1R37 640KB enabled, file=%s\r\n", celestiteCtrl->mz1r37FileName); + if (fileIdx == 0) + { + // First file param: MZ-1R12 CMOS RAM (32KB, doubler to 64KB via unlock D1h+12h). + // Initialise to 0xFF (empty battery-backed SRAM state). calloc zeros + // would cause the MZ-1500 IPL to think a valid extended ROM is present + // at offset 0x8000 (it checks for non-zero at that address to skip). + celestiteCtrl->mz1r12Ram = (uint8_t *) malloc(CELESTITE_R12_DBL); + if (celestiteCtrl->mz1r12Ram) + memset(celestiteCtrl->mz1r12Ram, 0xFF, CELESTITE_R12_DBL); + celestiteCtrl->mz1r12Size = CELESTITE_R12_SIZE; + if (celestiteCtrl->mz1r12Ram) + { + celestiteCtrl->mz1r12FileName = strdup(config->ifParam[p].file); + debugf("Celestite: MZ-1R12 32KB enabled, file=%s\r\n", celestiteCtrl->mz1r12FileName); - // Queue load from SD card. - t_CoreMsg msg; - memset(&msg, 0, sizeof(msg)); - msg.type = MSG_LOAD_RAMFILE; - msg.context = celestiteCtrl; - msg.requestId = 0xFD; // MZ-1R37 load sentinel. - strncpy(msg.fileOp.filename, celestiteCtrl->mz1r37FileName, MAX_IC_FILENAME_LEN - 1); - msg.fileOp.buffer = celestiteCtrl->mz1r37Ram; - msg.fileOp.size = CELESTITE_R37_SIZE; - queue_try_add(&cpu->requestQueue, &msg); + t_CoreMsg msg; + memset(&msg, 0, sizeof(msg)); + msg.type = MSG_LOAD_RAMFILE; + msg.context = celestiteCtrl; + msg.requestId = 0xFC; + strncpy(msg.fileOp.filename, celestiteCtrl->mz1r12FileName, MAX_IC_FILENAME_LEN - 1); + msg.fileOp.buffer = celestiteCtrl->mz1r12Ram; + msg.fileOp.size = CELESTITE_R12_SIZE; + queue_try_add(&cpu->requestQueue, &msg); + } + } + else if (fileIdx == 1) + { + // Second file param: MZ-1R37 EMM (640KB). + celestiteCtrl->mz1r37Ram = (uint8_t *) calloc(1, CELESTITE_R37_SIZE); + if (celestiteCtrl->mz1r37Ram) + { + celestiteCtrl->mz1r37FileName = strdup(config->ifParam[p].file); + debugf("Celestite: MZ-1R37 640KB enabled, file=%s\r\n", celestiteCtrl->mz1r37FileName); + + t_CoreMsg msg; + memset(&msg, 0, sizeof(msg)); + msg.type = MSG_LOAD_RAMFILE; + msg.context = celestiteCtrl; + msg.requestId = 0xFD; + strncpy(msg.fileOp.filename, celestiteCtrl->mz1r37FileName, MAX_IC_FILENAME_LEN - 1); + msg.fileOp.buffer = celestiteCtrl->mz1r37Ram; + msg.fileOp.size = CELESTITE_R37_SIZE; + queue_try_add(&cpu->requestQueue, &msg); + } + } + fileIdx++; } } @@ -415,6 +469,10 @@ uint8_t Celestite_Init(t_Z80CPU *cpu, t_drvIFConfig *config) case 0x69: cpu->_z80PSRAM->ioPtr[idx] = (t_MemoryFunc) Celestite_IO_UFM; break; + case 0x6A: + case 0x6B: + cpu->_z80PSRAM->ioPtr[idx] = (t_MemoryFunc) Celestite_IO_Cfg; + break; case 0x6F: cpu->_z80PSRAM->ioPtr[idx] = (t_MemoryFunc) Celestite_IO_Unlock; break; @@ -523,8 +581,17 @@ uint8_t __func_in_RAM(Celestite_PollCB)(t_Z80CPU *cpu) // Ping results use the memoReg which is already written by PollCB's ping response handling. // NET_CFG and socket results are now handled inline in the IDM read handler. - // Periodic recv polling: throttled, only when established sockets exist. + // Clear write-pending flags periodically (~6s at 3.5MHz) so the next batch + // of writes can queue a new scheduled write. The coalesce timeouts on Core 0 + // (2s for R12, 5s for R37) will have completed by then. celestiteCtrl->pollCounter++; + if ((celestiteCtrl->pollCounter & 0x1FFFFF) == 0) + { + celestiteCtrl->mz1r12WritePending = false; + celestiteCtrl->mz1r37WritePending = false; + } + + // Periodic recv polling: throttled, only when established sockets exist. if ((celestiteCtrl->pollCounter & 0xFFF) == 0) { for (int s = 0; s < W5100_NUM_SOCKETS; s++) @@ -723,27 +790,51 @@ uint8_t __func_in_RAM(Celestite_IO_DR)(t_Z80CPU *cpu, bool read, uint16_t addr, __dmb(); uint8_t rsn = celestiteCtrl->netRecvSock; uint32_t rlen = celestiteCtrl->netRecvLen; - celestiteCtrl->netRecvReady = false; + uint8_t rsr = celestiteCtrl->w5100Mem[rsn < W5100_NUM_SOCKETS ? + W5100_SOCK_BASE + (rsn * W5100_SOCK_SIZE) + Sn_SR : 0]; - if (rsn < W5100_NUM_SOCKETS && rlen > 0) + // Only process if socket is valid and still connected. + // Discard RECV results for closed sockets (ghost data from old connection). + if (rsn >= W5100_NUM_SOCKETS || rsr == SOCK_CLOSED) + { + celestiteCtrl->netRecvReady = false; + if (rsn < W5100_NUM_SOCKETS) + celestiteCtrl->netRecvPending[rsn] = false; + } + // Only copy data when the RX buffer is empty (RSR == 0). + // If RSR > 0, leave netRecvReady=true so data is copied on the + // next DR read after the Z80 has consumed the current buffer. + else if (rsn < W5100_NUM_SOCKETS) { uint16_t sb = W5100_SOCK_BASE + (rsn * W5100_SOCK_SIZE); - uint16_t rxBase = W5100_RX_BASE + (rsn * 0x0800); - uint16_t rxMask = 0x07FF; - uint16_t rxRd = ((uint16_t)celestiteCtrl->w5100Mem[sb + Sn_RX_RD0] << 8) | - celestiteCtrl->w5100Mem[sb + Sn_RX_RD0 + 1]; - for (uint32_t i = 0; i < rlen && i < 0x0800; i++) - { - uint16_t rxAddr = rxBase + ((rxRd + i) & rxMask); - celestiteCtrl->w5100Mem[rxAddr] = celestiteCtrl->netRecvBuf[i]; - } uint16_t curRsr = ((uint16_t)celestiteCtrl->w5100Mem[sb + Sn_RX_RSR0] << 8) | celestiteCtrl->w5100Mem[sb + Sn_RX_RSR0 + 1]; - curRsr += (uint16_t)rlen; - celestiteCtrl->w5100Mem[sb + Sn_RX_RSR0] = (uint8_t)(curRsr >> 8); - celestiteCtrl->w5100Mem[sb + Sn_RX_RSR0 + 1] = (uint8_t)(curRsr & 0xFF); - celestiteCtrl->w5100Mem[sb + Sn_IR] |= Sn_IR_RECV; - celestiteCtrl->netRecvPending[rsn] = false; + + if (curRsr == 0 || rlen == 0) + { + // Buffer empty or empty RECV result — safe to process. + celestiteCtrl->netRecvReady = false; + celestiteCtrl->netRecvPending[rsn] = false; + + if (rlen > 0) + { + uint16_t rxBase = W5100_RX_BASE + (rsn * 0x0800); + uint16_t rxMask = 0x07FF; + uint16_t rxRd = ((uint16_t)celestiteCtrl->w5100Mem[sb + Sn_RX_RD0] << 8) | + celestiteCtrl->w5100Mem[sb + Sn_RX_RD0 + 1]; + uint16_t copyLen = (rlen > 0x0800) ? 0x0800 : (uint16_t)rlen; + for (uint16_t i = 0; i < copyLen; i++) + { + uint16_t rxAddr = rxBase + ((rxRd + i) & rxMask); + celestiteCtrl->w5100Mem[rxAddr] = celestiteCtrl->netRecvBuf[i]; + } + celestiteCtrl->w5100Mem[sb + Sn_RX_RSR0] = (uint8_t)(copyLen >> 8); + celestiteCtrl->w5100Mem[sb + Sn_RX_RSR0 + 1] = (uint8_t)(copyLen & 0xFF); + celestiteCtrl->w5100Mem[sb + Sn_IR] |= Sn_IR_RECV; + } + } + // else: RSR > 0 and rlen > 0 — leave netRecvReady=true, + // data will be copied when RSR reaches 0 (after CMD_RECV). } } @@ -934,6 +1025,43 @@ uint8_t __func_in_RAM(Celestite_IO_UFM)(t_Z80CPU *cpu, bool read, uint16_t addr, return 0; } +// --------------------------------------------------------------------------- +// Ports 6Ah/6Bh — Configuration registers (index/data pair). +// Write index to 6Ah, read value from 6Bh. Extensible for future config items. +// Index 0-3: NET file server IP address bytes [0..3]. +// Index 4: NET file server port high byte. +// Index 5: NET file server port low byte. +// --------------------------------------------------------------------------- +uint8_t __func_in_RAM(Celestite_IO_Cfg)(t_Z80CPU *cpu, bool read, uint16_t addr, uint8_t data) +{ + (void)cpu; + uint8_t port = addr & 0xFF; + + if (port == 0x6A) + { + // Index register (write only). + if (!read) + celestiteCtrl->cfgIndex = data; + return 0xFF; + } + + // Port 0x6B — data register (read only). + if (read) + { + switch (celestiteCtrl->cfgIndex) + { + case 0: return celestiteCtrl->netSrvIP[0]; + case 1: return celestiteCtrl->netSrvIP[1]; + case 2: return celestiteCtrl->netSrvIP[2]; + case 3: return celestiteCtrl->netSrvIP[3]; + case 4: return (uint8_t)(celestiteCtrl->netSrvPort >> 8); + case 5: return (uint8_t)(celestiteCtrl->netSrvPort & 0xFF); + default: return 0xFF; + } + } + return 0; +} + // --------------------------------------------------------------------------- // Port 6Fh — Unlock register (write only, two-byte sequence: D1h then keyword). // Keywords: 12h=double MZ-1R12, 37h=enable EMM, 05h=unprotect ROM, @@ -1045,8 +1173,11 @@ uint8_t __func_in_RAM(Celestite_IO_R12)(t_Z80CPU *cpu, bool read, uint16_t addr, { celestiteCtrl->mz1r12Ram[celestiteCtrl->mz1r12Addr] = data; - // Schedule SD card write (coalesced, same pattern as standalone MZ-1R12). - if (celestiteCtrl->mz1r12FileName && celestiteCtrl->requestQueue) + // Schedule SD card write (coalesced). Only queue one message + // per coalesce window — the 2s timeout ensures the final state + // is written. Without this guard, every byte write floods the + // inter-core queue and starves other operations. + if (celestiteCtrl->mz1r12FileName && celestiteCtrl->requestQueue && !celestiteCtrl->mz1r12WritePending) { t_CoreMsg msg; memset(&msg, 0, sizeof(msg)); @@ -1057,8 +1188,8 @@ uint8_t __func_in_RAM(Celestite_IO_R12)(t_Z80CPU *cpu, bool read, uint16_t addr, msg.fileOp.timeout = 2000; // 2 second coalesce window. msg.fileOp.buffer = celestiteCtrl->mz1r12Ram; msg.fileOp.size = celestiteCtrl->mz1r12Size; - queue_try_add(celestiteCtrl->requestQueue, &msg); - celestiteCtrl->mz1r12WritePending = true; + if (queue_try_add(celestiteCtrl->requestQueue, &msg)) + celestiteCtrl->mz1r12WritePending = true; } } celestiteCtrl->mz1r12Addr++; @@ -1113,8 +1244,8 @@ uint8_t __func_in_RAM(Celestite_IO_R37Data)(t_Z80CPU *cpu, bool read, uint16_t a { celestiteCtrl->mz1r37Ram[fullAddr] = data; - // Schedule SD card write (coalesced). - if (celestiteCtrl->mz1r37FileName && celestiteCtrl->requestQueue) + // Schedule SD card write (coalesced, one message per window). + if (celestiteCtrl->mz1r37FileName && celestiteCtrl->requestQueue && !celestiteCtrl->mz1r37WritePending) { t_CoreMsg msg; memset(&msg, 0, sizeof(msg)); @@ -1125,7 +1256,8 @@ uint8_t __func_in_RAM(Celestite_IO_R37Data)(t_Z80CPU *cpu, bool read, uint16_t a msg.fileOp.timeout = 5000; // 5 second coalesce window (640KB is large). msg.fileOp.buffer = celestiteCtrl->mz1r37Ram; msg.fileOp.size = CELESTITE_R37_SIZE; - queue_try_add(celestiteCtrl->requestQueue, &msg); + if (queue_try_add(celestiteCtrl->requestQueue, &msg)) + celestiteCtrl->mz1r37WritePending = true; } } return 0; diff --git a/projects/tzpuPico/src/drivers/Sharp/MZ1500.c b/projects/tzpuPico/src/drivers/Sharp/MZ1500.c index b2f1fef..adb7d23 100644 --- a/projects/tzpuPico/src/drivers/Sharp/MZ1500.c +++ b/projects/tzpuPico/src/drivers/Sharp/MZ1500.c @@ -537,6 +537,88 @@ void MZ1500_readFileData(void *ctx, void *cfg, int filepos, char *buf, int len) return; } +// Callbacks for driver-level ROM loading. Each ROM type has its own callback +// that knows where to place the data. The config.json "rom" array has 3 entries: +// rom[0] = Monitor ROM I (4K) → Bank 0 at 0x0000-0x0FFF +// rom[1] = Extended ROM (6K) → Bank 0 at 0xE800-0xFFFF (E000-E7FF is hardware I/O, not enabled by ROM select) +// rom[2] = CG-ROM (4K) → Bank 39 at 0xD000-0xDFFF +// No loadaddr needed — the romIndex determines the destination. + +// ROM index constants for driver-level ROM array. +#define MZ1500_ROM_MONITOR 0 // 4K Monitor ROM at 0x0000-0x0FFF. +#define MZ1500_ROM_EXTENDED 1 // 6K Extended ROM at 0xE800-0xFFFF. +#define MZ1500_ROM_CGROM 2 // 4K CG-ROM at Bank 39 0xD000-0xDFFF. + +static int mz1500_romLoadIndex = 0; + +void MZ1500_readDriverROM(void *ctx, void *cfg, char *buf, int len) +{ + t_Z80CPU *cpu = (t_Z80CPU *) ctx; + (void)cfg; + + if (cpu == NULL || buf == NULL || len <= 0) + return; + + int romIdx = mz1500_romLoadIndex++; + + switch (romIdx) + { + case MZ1500_ROM_MONITOR: + { + // Monitor ROM I: 4K → Bank 0 at 0x0000. + uint32_t copyLen = (len > 0x1000) ? 0x1000 : (uint32_t)len; + uint32_t dstAddr = (MZ1500_MEMBANK_0 * MEMORY_PAGE_SIZE) + 0x0000; + for (uint32_t i = 0; i < copyLen; i++) + cpu->_z80PSRAM->RAM[dstAddr + i] = buf[i]; + debugf("MZ1500_ROM: Monitor ROM loaded (%d bytes → Bank 0 : 0x0000)\r\n", copyLen); + break; + } + case MZ1500_ROM_EXTENDED: + { + // Extended ROM: 6K → Bank 0 at 0xE800. + // E000-E7FF is hardware I/O (not ROM), so ROM maps E800-FFFF. + // If file is >6K (8K = full E000-FFFF dump), skip first 2K (the E000-E7FF gap). + uint32_t srcOfs = 0; + uint32_t dataLen = (uint32_t)len; + if (dataLen > 0x1800) + { + srcOfs = dataLen - 0x1800; // Skip leading bytes (E000-E7FF region). + dataLen = 0x1800; + } + uint32_t dstAddr = (MZ1500_MEMBANK_0 * MEMORY_PAGE_SIZE) + 0xE800; + for (uint32_t i = 0; i < dataLen; i++) + cpu->_z80PSRAM->RAM[dstAddr + i] = buf[srcOfs + i]; + debugf("MZ1500_ROM: Extended ROM loaded (%d bytes, srcOfs=%d → Bank 0 : 0xE800)\r\n", dataLen, srcOfs); + break; + } + case MZ1500_ROM_CGROM: + { + // CG-ROM: 4K → Bank 39 at 0xD000. + uint32_t copyLen = (len > 0x1000) ? 0x1000 : (uint32_t)len; + uint32_t dstAddr = (MZ1500_MEMBANK_CGROM * MEMORY_PAGE_SIZE) + 0xD000; + for (uint32_t i = 0; i < copyLen; i++) + cpu->_z80PSRAM->RAM[dstAddr + i] = buf[i]; + debugf("MZ1500_ROM: CG-ROM loaded (%d bytes → PSRAM 0x%06X)\r\n", copyLen, dstAddr); + // Dump at offset 0xA00 where Japanese/MZ-700 ROMs differ (lowercase chars). + if (copyLen > 0xA08) + { + debugf("MZ1500_ROM: CG buf[A00]: %02X %02X %02X %02X %02X %02X %02X %02X\r\n", + (uint8_t)buf[0xA00], (uint8_t)buf[0xA01], (uint8_t)buf[0xA02], (uint8_t)buf[0xA03], + (uint8_t)buf[0xA04], (uint8_t)buf[0xA05], (uint8_t)buf[0xA06], (uint8_t)buf[0xA07]); + debugf("MZ1500_ROM: PSRAM[DA00]: %02X %02X %02X %02X %02X %02X %02X %02X\r\n", + cpu->_z80PSRAM->RAM[dstAddr+0xA00], cpu->_z80PSRAM->RAM[dstAddr+0xA01], + cpu->_z80PSRAM->RAM[dstAddr+0xA02], cpu->_z80PSRAM->RAM[dstAddr+0xA03], + cpu->_z80PSRAM->RAM[dstAddr+0xA04], cpu->_z80PSRAM->RAM[dstAddr+0xA05], + cpu->_z80PSRAM->RAM[dstAddr+0xA06], cpu->_z80PSRAM->RAM[dstAddr+0xA07]); + } + break; + } + default: + debugf("MZ1500_ROM: unexpected ROM index %d, ignoring\r\n", romIdx); + break; + } +} + // Method to write out a stored ROM into the internal emulation RAM as directed by the configuration array. // Each config element specifies a position in the ROM and the target within the memory. void MZ1500_readROMData(void *ctx, void *cfg, char *buf, int len) @@ -651,14 +733,13 @@ uint8_t __func_in_RAM(MZ1500_IO_MemoryBankPorts)(t_Z80CPU *cpu, bool read, uint1 MZ1500Ctrl.loDRAMen = true; } - // E1h: D000-FFFF -> DRAM. Does NOT close PCG bank on MZ-1500 (bank switch happens behind the scenes). + // E1h: D000-EFFF -> DRAM. Does NOT close PCG bank on MZ-1500. + // F000-FFFF excluded — handled by priority logic below (PCG > DRAM > ROM). // E000-E7FF (8255/8253 hardware I/O) must ALWAYS remain physical — skip blocks 112-115. if (!MZ1500Ctrl.inhibit && port == 0xe1 && !MZ1500Ctrl.hiDRAMen) { - // Enable upper 12K block as DRAM, but preserve E000-E7FF as PHYSICAL_HW. - for (int idx = (0xd000 / MEMORY_BLOCK_SIZE); idx < (0x10000 / MEMORY_BLOCK_SIZE); idx++) + for (int idx = (0xd000 / MEMORY_BLOCK_SIZE); idx < (0xF000 / MEMORY_BLOCK_SIZE); idx++) { - // Skip E000-E7FF (blocks 112-115) — 8255/8253 must always be on physical bus. if (idx >= (0xE000 / MEMORY_BLOCK_SIZE) && idx < (0xE800 / MEMORY_BLOCK_SIZE)) continue; MZ1500Ctrl.upmembankPtr[idx - (0xd000 / MEMORY_BLOCK_SIZE)] = cpu->_membankPtr[idx]; @@ -669,7 +750,7 @@ uint8_t __func_in_RAM(MZ1500_IO_MemoryBankPorts)(t_Z80CPU *cpu, bool read, uint1 if ((MZ1500Ctrl.loDRAMen && port == 0xe2) || port == 0xe4) { - // Enable lower 4K block as Monitor ROM + // Enable lower 4K block as Monitor ROM. for (int idx = 0x0000; idx < (0x1000 / MEMORY_BLOCK_SIZE); idx++) { cpu->_membankPtr[idx] = (MEMBANK_TYPE_ROM << 24) | (MZ1500_MEMBANK_0 << 16) | (idx * MEMORY_BLOCK_SIZE); @@ -677,11 +758,11 @@ uint8_t __func_in_RAM(MZ1500_IO_MemoryBankPorts)(t_Z80CPU *cpu, bool read, uint1 MZ1500Ctrl.loDRAMen = false; } - // E3h: D000-FFFF -> VRAM/KEY/TIMER. Does NOT close PCG bank on MZ-1500. + // E3h/E4h: D000-EFFF -> VRAM/KEY/TIMER. Does NOT close PCG bank on MZ-1500. + // F000-FFFF excluded — handled by priority logic below. if ((!MZ1500Ctrl.inhibit && MZ1500Ctrl.hiDRAMen && port == 0xe3) || (MZ1500Ctrl.hiDRAMen && port == 0xe4)) { - // Restore upper 12K block to Memory mapped I/O, skip E000-E7FF (always physical). - for (int idx = (0xd000 / MEMORY_BLOCK_SIZE); idx < (0x10000 / MEMORY_BLOCK_SIZE); idx++) + for (int idx = (0xd000 / MEMORY_BLOCK_SIZE); idx < (0xF000 / MEMORY_BLOCK_SIZE); idx++) { if (idx >= (0xE000 / MEMORY_BLOCK_SIZE) && idx < (0xE800 / MEMORY_BLOCK_SIZE)) continue; @@ -696,11 +777,6 @@ uint8_t __func_in_RAM(MZ1500_IO_MemoryBankPorts)(t_Z80CPU *cpu, bool read, uint1 MZ1500Ctrl.inhibit = false; if (MZ1500Ctrl.mode == MZ1500_MODE_MZ1500 && MZ1500Ctrl.pcgBankOpen) { - // Restore F000-FFFF from saved mapping. - for (int idx = (0xF000 / MEMORY_BLOCK_SIZE); idx < (0x10000 / MEMORY_BLOCK_SIZE); idx++) - { - cpu->_membankPtr[idx] = MZ1500Ctrl.pcgSavedBankPtr[idx - (0xF000 / MEMORY_BLOCK_SIZE)]; - } MZ1500Ctrl.pcgBankOpen = false; } } @@ -711,16 +787,6 @@ uint8_t __func_in_RAM(MZ1500_IO_MemoryBankPorts)(t_Z80CPU *cpu, bool read, uint1 { // MZ-1500: Open PCG bank at F000-FFFF. // Bits 0-1 select: 00=CGROM, 01=PCG blue, 10=PCG red, 11=PCG green. - // Save current F000-FFFF mapping (e.g., FDD ROM) and switch to PHYSICAL - // so reads go to the real CGROM/PCG-RAM on the bus. - if (!MZ1500Ctrl.pcgBankOpen) - { - for (int idx = (0xF000 / MEMORY_BLOCK_SIZE); idx < (0x10000 / MEMORY_BLOCK_SIZE); idx++) - { - MZ1500Ctrl.pcgSavedBankPtr[idx - (0xF000 / MEMORY_BLOCK_SIZE)] = cpu->_membankPtr[idx]; - cpu->_membankPtr[idx] = (MEMBANK_TYPE_PHYSICAL << 24) | (idx * MEMORY_BLOCK_SIZE); - } - } MZ1500Ctrl.pcgBankSelect = data & 0x03; MZ1500Ctrl.pcgBankOpen = true; } @@ -742,14 +808,6 @@ uint8_t __func_in_RAM(MZ1500_IO_MemoryBankPorts)(t_Z80CPU *cpu, bool read, uint1 { if (MZ1500Ctrl.mode == MZ1500_MODE_MZ1500) { - // MZ-1500: Close PCG bank. Restore F000-FFFF to saved mapping (FDD ROM etc). - if (MZ1500Ctrl.pcgBankOpen) - { - for (int idx = (0xF000 / MEMORY_BLOCK_SIZE); idx < (0x10000 / MEMORY_BLOCK_SIZE); idx++) - { - cpu->_membankPtr[idx] = MZ1500Ctrl.pcgSavedBankPtr[idx - (0xF000 / MEMORY_BLOCK_SIZE)]; - } - } MZ1500Ctrl.pcgBankOpen = false; } else @@ -768,6 +826,62 @@ uint8_t __func_in_RAM(MZ1500_IO_MemoryBankPorts)(t_Z80CPU *cpu, bool read, uint1 } } + // MZ-1500 mode: compute memory mapping for D000-FFFF from current flags. + // Per manual p.62-63: $E5 affects the entire $D000~$FFFF range. + // E000-E7FF (hardware I/O) is always physical and skipped. + if (MZ1500Ctrl.mode == MZ1500_MODE_MZ1500) + { + if (MZ1500Ctrl.pcgBankOpen) + { + // $E5 active: D000-FFFF (minus E000-E7FF) changes based on bank select. + if (MZ1500Ctrl.pcgBankSelect == MZ1500_PCG_BANK_CGROM) + { + // Bank=0 (CG-ROM): D000-DFFF → PSRAM Bank 39 at D000 (virtual CG-ROM, + // allows user override via config.json rom[2]). + for (int idx = (0xD000 / MEMORY_BLOCK_SIZE); idx < (0xE000 / MEMORY_BLOCK_SIZE); idx++) + { + cpu->_membankPtr[idx] = (MEMBANK_TYPE_ROM << 24) | (MZ1500_MEMBANK_CGROM << 16) | (idx * MEMORY_BLOCK_SIZE); + } + } + else + { + // Bank=1-3 (PCG planes): D000-DFFF → PHYSICAL (writes must hit real PCG RAM + // so the display hardware can read them for video output). + for (int idx = (0xD000 / MEMORY_BLOCK_SIZE); idx < (0xE000 / MEMORY_BLOCK_SIZE); idx++) + { + cpu->_membankPtr[idx] = (MEMBANK_TYPE_PHYSICAL << 24) | (MZ1500_MEMBANK_0 << 16) | (idx * MEMORY_BLOCK_SIZE); + } + } + // E800-FFFF → PHYSICAL when $E5 active (hardware routing). + for (int idx = (0xE800 / MEMORY_BLOCK_SIZE); idx < (0x10000 / MEMORY_BLOCK_SIZE); idx++) + { + cpu->_membankPtr[idx] = (MEMBANK_TYPE_PHYSICAL << 24) | (MZ1500_MEMBANK_0 << 16) | (idx * MEMORY_BLOCK_SIZE); + } + } + else if (MZ1500Ctrl.hiDRAMen) + { + // $E1 active: D000-FFFF (minus E000-E7FF) → DRAM (Bank 1). + for (int idx = (0xD000 / MEMORY_BLOCK_SIZE); idx < (0x10000 / MEMORY_BLOCK_SIZE); idx++) + { + if (idx >= (0xE000 / MEMORY_BLOCK_SIZE) && idx < (0xE800 / MEMORY_BLOCK_SIZE)) + continue; + cpu->_membankPtr[idx] = (MEMBANK_TYPE_RAM << 24) | (MZ1500_MEMBANK_1 << 16) | (idx * MEMORY_BLOCK_SIZE); + } + } + else + { + // Default ($E3/$E4): D000-DFFF=VRAM, E800-FFFF=ROM (MZ-MON-ROM II in PSRAM). + for (int idx = (0xD000 / MEMORY_BLOCK_SIZE); idx < (0xE000 / MEMORY_BLOCK_SIZE); idx++) + { + cpu->_membankPtr[idx] = (MEMBANK_TYPE_PHYSICAL_VRAM << 24) | (MZ1500_MEMBANK_0 << 16) | (idx * MEMORY_BLOCK_SIZE); + } + for (int idx = (0xE800 / MEMORY_BLOCK_SIZE); idx < (0x10000 / MEMORY_BLOCK_SIZE); idx++) + { + cpu->_membankPtr[idx] = (MEMBANK_TYPE_ROM << 24) | (MZ1500_MEMBANK_0 << 16) | (idx * MEMORY_BLOCK_SIZE); + } + } + } + return (0); } @@ -866,19 +980,23 @@ uint8_t MZ1500_Reset(t_Z80CPU *cpu) MZ1500Ctrl.loDRAMen = false; MZ1500Ctrl.hiDRAMen = false; - // Reset MZ-1500-specific PCG state. Restore F000-FFFF if PCG bank was open. - if (MZ1500Ctrl.pcgBankOpen) - { - for (int idx = (0xF000 / MEMORY_BLOCK_SIZE); idx < (0x10000 / MEMORY_BLOCK_SIZE); idx++) - { - cpu->_membankPtr[idx] = MZ1500Ctrl.pcgSavedBankPtr[idx - (0xF000 / MEMORY_BLOCK_SIZE)]; - } - } + // Reset MZ-1500-specific PCG state. MZ1500Ctrl.pcgBankOpen = false; MZ1500Ctrl.pcgBankSelect = MZ1500_PCG_BANK_CGROM; MZ1500Ctrl.pcgPriority = 0; MZ1500Ctrl.pcgPalette = 0; + // Restore D000-FFFF to default state (hardware RESET already resets physical latches). + // D000-DFFF=VRAM, E000-E7FF stays PHYSICAL_HW, E800-FFFF=ROM (PSRAM). + for (int idx = (0xD000 / MEMORY_BLOCK_SIZE); idx < (0xE000 / MEMORY_BLOCK_SIZE); idx++) + { + cpu->_membankPtr[idx] = (MEMBANK_TYPE_PHYSICAL_VRAM << 24) | (MZ1500_MEMBANK_0 << 16) | (idx * MEMORY_BLOCK_SIZE); + } + for (int idx = (0xE800 / MEMORY_BLOCK_SIZE); idx < (0x10000 / MEMORY_BLOCK_SIZE); idx++) + { + cpu->_membankPtr[idx] = (MEMBANK_TYPE_ROM << 24) | (MZ1500_MEMBANK_0 << 16) | (idx * MEMORY_BLOCK_SIZE); + } + // Reset PIT emulation state so it re-initializes on next access. pitInitialized = false; @@ -1035,9 +1153,15 @@ uint8_t MZ1500_Init(t_Z80CPU *cpu, t_FlashAppConfigHeader *appConfig, t_drvConfi switch (idx) { // 0x0000:0x0FFF = Monitor ROM. - // Stays physical for fetches unless overridden by ROM config. - // Physical ROM is mirrored into PSRAM at init for bank switching reference. + // Virtual mode: runs from PSRAM (mirrored from physical during init, + // or overridden by user ROM via config.json). case 0 ... 7: + if (!isPhysical) + { + memType = MEMBANK_TYPE_ROM; + waitStates = 1; + tCycSync = true; + } break; // 0x1000:0xCFFF = RAM @@ -1062,7 +1186,7 @@ uint8_t MZ1500_Init(t_Z80CPU *cpu, t_FlashAppConfigHeader *appConfig, t_drvConfi memType = MEMBANK_TYPE_PHYSICAL_HW; break; - // 0xE800:0xEFFF = Empty + // 0xE800:0xEFFF = MZ-MON-ROM II (part 1). case 116 ... 119: if (!isPhysical) { @@ -1072,13 +1196,13 @@ uint8_t MZ1500_Init(t_Z80CPU *cpu, t_FlashAppConfigHeader *appConfig, t_drvConfi } break; - // 0xF000:0xFFFF = Empty + // 0xF000:0xFFFF = MZ-MON-ROM II (part 2, F88C+ unused/0xFF). case 120 ... 127: if (!isPhysical) { + memType = MEMBANK_TYPE_ROM; waitStates = 1; tCycSync = true; - memType = MEMBANK_TYPE_ROM; } break; } @@ -1134,11 +1258,23 @@ uint8_t MZ1500_Init(t_Z80CPU *cpu, t_FlashAppConfigHeader *appConfig, t_drvConfi // Copy monitor ROM to Bank 0. Z80CPU_taskMirrorPhysicalToInternalRAM(cpu, (MZ1500_MEMBANK_0 << 16) | 0x0000, 0x0000, 0x1000); - // Copy User ROM to Bank 0. + // Copy MZ-MON-ROM II to Bank 0 (E800-FFFF, contiguous IPL ROM). Z80CPU_taskMirrorPhysicalToInternalRAM(cpu, (MZ1500_MEMBANK_0 << 16) | 0xE800, 0xE800, 0x800); - - // Copy Floppy ROM to Bank 0. Z80CPU_taskMirrorPhysicalToInternalRAM(cpu, (MZ1500_MEMBANK_0 << 16) | 0xF000, 0xF000, 0x1000); + + // Mirror CG-ROM to Bank 39 at D000-DFFF (4K). + // The CG-ROM is only accessible via $E5 bank=0, which maps it to D000-DFFF. + // Select CG-ROM on the physical bus, copy it, then restore default state. + // User-provided CG-ROM (rom[2] in config.json) overrides this mirror. + Z80CPU_writePhysicalIO(cpu, 0xE5, 0x00); // Select CG-ROM at D000-DFFF. + Z80CPU_taskMirrorPhysicalToInternalRAM(cpu, (MZ1500_MEMBANK_CGROM << 16) | 0xD000, 0xD000, 0x1000); + Z80CPU_writePhysicalIO(cpu, 0xE4, 0x00); // Restore default memory map. + + // Note: The physical CGROM chip (Japanese) is read directly by the display + // hardware for text-only programs. The virtual CG-ROM in Bank 39 is only + // visible to the Z80 CPU via E5h bank=0. Programs that use PCG will read + // the virtual CG-ROM and program PCG planes, which the display then uses. + // Text-only programs (BASIC, monitor) show physical CGROM characters. } // Initialise driver control state variables. @@ -1168,6 +1304,27 @@ uint8_t MZ1500_Init(t_Z80CPU *cpu, t_FlashAppConfigHeader *appConfig, t_drvConfi // Install task processing handler. config->taskPtr = MZ1500_TaskProcessor; + // Load driver-level ROM(s) if provided in config.json. + // rom[0]=Monitor(4K), rom[1]=Extended(6K), rom[2]=CG-ROM(4K). + // These override the physical ROM mirrors with user-supplied ROM data. + mz1500_romLoadIndex = 0; + debugf("MZ1500_Init: romCount=%d\r\n", config->romCount); + for (int romIdx = 0; romIdx < config->romCount; romIdx++) + { + if (config->romConfig[romIdx].romFile != NULL) + { + debugf("MZ1500_Init: loading ROM '%s'\r\n", config->romConfig[romIdx].romFile); + Z80CPU_ReadROM(appConfig, + config->romConfig[romIdx].romFile, + cpu, + NULL, + MZ1500_readDriverROM, + NULL, + 0, + 0); + } + } + // Go through each interface and perform necessary configuration. for (int ifIdx = 0; ifIdx < config->ifCount; ifIdx++) { diff --git a/projects/tzpuPico/src/include/Z80CPU.h b/projects/tzpuPico/src/include/Z80CPU.h index baef33c..0bb5593 100644 --- a/projects/tzpuPico/src/include/Z80CPU.h +++ b/projects/tzpuPico/src/include/Z80CPU.h @@ -354,9 +354,11 @@ typedef struct } t_ioReMap; // Struct for storing interface configuration parameters. +// Each param entry is a key/value pair — the key determines the parameter type. typedef struct { - char *file; // Name of a file required by the interface for initialisation. + char *file; // "file" key: name of a file required by the interface. + char *ip; // "ip" key: IP address string, e.g. "192.168.1.210:6800". } t_ifParam; // Struct for storing a drivers interface configuration. @@ -379,6 +381,8 @@ struct t_drvConfig { const char *name; // Name of the driver. 1:1 mapping with t_VirtualFuncMap.virtualFuncName bool isPhysical; // Should the driver use physical mappings (ROM/RAM etc) or virtual? + int romCount; // Number of driver-level ROM's (e.g., system ROM replacement). + t_drvROMConfig *romConfig; // Struct array of driver-level ROM descriptions. int ifCount; // Number of interfaces stored. t_drvIFConfig *ifConfig; // Driver interface configuration. Each driver can have multiple optional interfaces. t_ResetFunc resetPtr; // Pointer to a reset handler for this driver. diff --git a/projects/tzpuPico/src/include/drivers/Sharp/Celestite.h b/projects/tzpuPico/src/include/drivers/Sharp/Celestite.h index 2064e72..7527a8d 100644 --- a/projects/tzpuPico/src/include/drivers/Sharp/Celestite.h +++ b/projects/tzpuPico/src/include/drivers/Sharp/Celestite.h @@ -22,6 +22,11 @@ // 68h (W) : UFM address register // 68h (R) : UFM status (bit 7: WP, bit 1: READY, bit 0: BUSY) // 69h (R/W) : UFM data register +// 6Ah (W) : Config index register (selects config byte) +// 6Bh (R) : Config data register (returns selected byte) +// Index 0-3: NET file server IP[0..3] +// Index 4: NET file server port high byte +// Index 5: NET file server port low byte // 6Fh (W) : Unlock (write D1h then keyword) // // Credits: Board designed by Oh!Ishi (Oh!石) @@ -210,6 +215,13 @@ typedef struct uint8_t *mz1r37Ram; // 640KB RAM buffer. uint32_t mz1r37AddrLatch; // Latched address bits [19:8]. char *mz1r37FileName; // SD card backing file name (NULL = no persistence). + bool mz1r37WritePending; // True while a scheduled write is queued/in-flight. + + // NET file server configuration (read via ports 6Ah/6Bh). + // Parsed from ifParam[2].file = "ip:port" (e.g., "192.168.1.210:6800"). + uint8_t netSrvIP[4]; // File server IP address (default 0.0.0.0 = unconfigured). + uint16_t netSrvPort; // File server TCP port (default 6800). + uint8_t cfgIndex; // Config register index (port 6Ah write). // Network state (Phase 2). queue_t *requestQueue; // Pointer to inter-core request queue. @@ -256,5 +268,6 @@ uint8_t Celestite_IO_Unlock(t_Z80CPU *cpu, bool read, uint16_t addr, uint8_t dat uint8_t Celestite_IO_R12(t_Z80CPU *cpu, bool read, uint16_t addr, uint8_t data); uint8_t Celestite_IO_R37Latch(t_Z80CPU *cpu, bool read, uint16_t addr, uint8_t data); uint8_t Celestite_IO_R37Data(t_Z80CPU *cpu, bool read, uint16_t addr, uint8_t data); +uint8_t Celestite_IO_Cfg(t_Z80CPU *cpu, bool read, uint16_t addr, uint8_t data); #endif // CELESTITE_H diff --git a/projects/tzpuPico/src/include/drivers/Sharp/MZ1500.h b/projects/tzpuPico/src/include/drivers/Sharp/MZ1500.h index 7227ed0..156d436 100644 --- a/projects/tzpuPico/src/include/drivers/Sharp/MZ1500.h +++ b/projects/tzpuPico/src/include/drivers/Sharp/MZ1500.h @@ -69,6 +69,7 @@ // Constants. #define MZ1500_MEMBANK_0 0 // Primary RAM bank. #define MZ1500_MEMBANK_1 1 // RAM bank to use for paging in RAM. +#define MZ1500_MEMBANK_CGROM 39 // Bank for CG-ROM storage (D000-DFFF = 4K). Banks 40-63 reserved for RFS. #define MZ1500_UPPERMEM_BLOCKS 64 // Number of blocks in the upper paged 12K RAM. // PCG constants. diff --git a/projects/tzpuPico/src/include/ipc_protocol.h b/projects/tzpuPico/src/include/ipc_protocol.h index 2da9bcc..5f451cf 100644 --- a/projects/tzpuPico/src/include/ipc_protocol.h +++ b/projects/tzpuPico/src/include/ipc_protocol.h @@ -57,6 +57,13 @@ #define IPCF_CMD_NET_RECV 0x13 // Receive data from socket (response payload = data) #define IPCF_CMD_NET_PING 0x14 // ICMP echo request (ping) +// Network file server commands (BASIC NET: device → ESP32 → PC file server) +#define IPCF_CMD_NETFS_DIR 0x20 // Get directory from file server (response: 32-byte entries) +#define IPCF_CMD_NETFS_INFO 0x21 // Get file info (filename in header, response: atrb+size+addrs) +#define IPCF_CMD_NETFS_READ 0x22 // Read file data (filename+offset in header, response: data) +#define IPCF_CMD_NETFS_WRITE 0x23 // Write file (filename+size in header, payload: data) +#define IPCF_CMD_NET_PING 0x14 // ICMP echo request (ping) + // --------------------------------------------------------------------------- // Status codes (t_IpcFrameHdr::status — response frames only) // --------------------------------------------------------------------------- diff --git a/projects/tzpuPico/src/model/BaseZ80/main.c b/projects/tzpuPico/src/model/BaseZ80/main.c index 5391d5f..fd5cba5 100644 --- a/projects/tzpuPico/src/model/BaseZ80/main.c +++ b/projects/tzpuPico/src/model/BaseZ80/main.c @@ -1097,7 +1097,7 @@ void processInterCoreCommands(void) { responseType = MSG_LOAD_COMPLETE; uint8_t *bufBefore = qmsg.fileOp.buffer; - for (int attempt = 0; attempt < 10; attempt++) + for (int attempt = 0; attempt < 3; attempt++) { qmsg.fileOp.buffer = bufBefore; bytesXfer = ESP_readRamFile(qmsg.fileOp.filename, NULL, NULL, NULL, (uint8_t **)&qmsg.fileOp.buffer, qmsg.fileOp.size, true, 0); @@ -1111,6 +1111,16 @@ void processInterCoreCommands(void) success = true; response.response.size = bytesXfer; } + else + { + // File doesn't exist — treat as fresh uninitialised RAM board. + // Buffer is already pre-filled by the driver (0xFF for Celestite + // MZ-1R12, matching empty battery-backed SRAM). The backing file + // will be created on first save from the Z80. + debugf("RAMFILE: '%s' not found, using pre-filled buffer (%d bytes)\r\n", qmsg.fileOp.filename, (int) qmsg.fileOp.size); + success = true; + response.response.size = qmsg.fileOp.size; + } } else { diff --git a/projects/tzpuPico/src/model/BaseZ80/version.txt b/projects/tzpuPico/src/model/BaseZ80/version.txt index 6a4b44e..0a01833 100644 --- a/projects/tzpuPico/src/model/BaseZ80/version.txt +++ b/projects/tzpuPico/src/model/BaseZ80/version.txt @@ -1 +1 @@ -2.534 +2.579 diff --git a/projects/tzpuPico/src/test/Celestite/celestite_stress.mzf b/projects/tzpuPico/src/test/Celestite/celestite_stress.mzf new file mode 100644 index 0000000..3466881 Binary files /dev/null and b/projects/tzpuPico/src/test/Celestite/celestite_stress.mzf differ diff --git a/projects/tzpuPico/src/test/Celestite/celestite_stress.sym b/projects/tzpuPico/src/test/Celestite/celestite_stress.sym new file mode 100644 index 0000000..396f9d0 --- /dev/null +++ b/projects/tzpuPico/src/test/Celestite/celestite_stress.sym @@ -0,0 +1,67 @@ +A2DSP: equ 14A7H +ATBL: equ 1568H +ATRB: equ 10F0H +CLR: equ 122EH +COMNT: equ 1108H +DBGCR: equ 1563H +DBGLP: equ 155BH +DLY1: equ 143FH +DLY2: equ 1442H +DTADR: equ 1104H +EXADR: equ 1106H +HTCHK: equ 1405H +HTDISP: equ 13BFH +HTEND: equ 140DH +HTNL: equ 13D4H +HTNL2: equ 13E1H +HTNL3: equ 13E6H +HTNL4: equ 13EFH +HTNL5: equ 13F8H +HTNL6: equ 13FEH +HTNXT: equ 1405H +HTTPDN: equ 1419H +HTTPGOT: equ 13A0H +HTTPRD: equ 138DH +HTTPREQ: equ 1548H +HTTPRW: equ 1376H +HTTPWD: equ 1354H +HTTPWR: equ 134BH +LOOPCNT: equ 1566H +MAINLP: equ 1228H +MEND: equ 15E8H +NAME: equ 10F1H +PDECV: equ 145BH +PH1: equ 14CBH +PH2: equ 14D8H +PHEX: equ 14BCH +PHEXV: equ 148CH +PHXN: equ 1499H +PHXN1: equ 14A2H +PNGD: equ 1274H +PNGDN: equ 1292H +PNGGOT: equ 1286H +PNGW: equ 1269H +PSTR: equ 14B4H +PVD1: equ 147CH +PVD10: equ 1467H +PVD100: equ 145EH +PVD10L: equ 1473H +PVD10N: equ 1470H +PVD1N: equ 1485H +PVRAM: equ 144CH +SIZE: equ 1102H +START: equ 1200H +S_BYT: equ 1546H +S_FAIL: equ 1539H +S_HTTP: equ 1523H +S_LOOP: equ 14F5H +S_MS: equ 1543H +S_PASS: equ 1534H +S_PING: equ 14FBH +S_TCP: equ 150CH +S_TITLE: equ 14DBH +S_TOUT: equ 153EH +TCPD: equ 130BH +TCPFL: equ 1312H +TCPOK: equ 131EH +TCPW: equ 12F3H diff --git a/projects/tzpuPico/src/test/Celestite/celestite_test.mzf b/projects/tzpuPico/src/test/Celestite/celestite_test.mzf new file mode 100644 index 0000000..9875cae Binary files /dev/null and b/projects/tzpuPico/src/test/Celestite/celestite_test.mzf differ diff --git a/projects/tzpuPico/src/test/Celestite/celestite_test.sym b/projects/tzpuPico/src/test/Celestite/celestite_test.sym new file mode 100644 index 0000000..9b527a6 --- /dev/null +++ b/projects/tzpuPico/src/test/Celestite/celestite_test.sym @@ -0,0 +1,93 @@ +A2DSP: equ 170DH +ATBL: equ 1A51H +ATRB: equ 10F0H +BNRMSG: equ 172DH +BYTEMSG: equ 19C0H +COMNT: equ 1108H +CRLF: equ 19F6H +DONEMSG: equ 1A08H +DTADR: equ 1104H +EXADR: equ 1106H +FAILMSG: equ 18D0H +HTTPREQ: equ 1A27H +MEND: equ 1AD1H +MSMSG: equ 1A03H +NAME: equ 10F1H +NETMSG: equ 18D7H +OKMSG: equ 19C9H +PASSMSG: equ 18C9H +PAUSMSG: equ 19D7H +PBANNER: equ 1726H +PD10: equ 16D0H +PD100: equ 16B9H +PD10N: equ 16CDH +PD10S: equ 16C2H +PD1N: equ 16E7H +PD1P: equ 16E2H +PD1S: equ 16D9H +PDEC: equ 16B2H +PHEX: equ 16EEH +PHX1: equ 16FDH +PHX2: equ 170AH +PMSG: equ 171DH +PSTR: equ 16AAH +RESULT: equ 169AH +RPASS: equ 16A3H +RXGMSG: equ 19B6H +RXWMSG: equ 199EH +SCRHDR: equ 1A3AH +SIZE: equ 1102H +START: equ 1200H +T10MSG: equ 1875H +T11MSG: equ 1891H +T12MSG: equ 18ADH +T13DLY: equ 1407H +T13DONE: equ 141AH +T13GOT: equ 1416H +T13MSG: equ 191BH +T13WAIT: equ 13F6H +T14MSG: equ 1937H +T15DLY: equ 1463H +T15DONE: equ 147DH +T15GOT: equ 1472H +T15MSG: equ 1948H +T15WAIT: equ 1458H +T16DLY: equ 14ECH +T16DONE: equ 150BH +T16FAIL: equ 14F3H +T16MSG: equ 1964H +T16OK: equ 14FBH +T16WAIT: equ 14D5H +T17CD: equ 1581H +T17CLR: equ 161BH +T17CONN: equ 158BH +T17CW: equ 1569H +T17DISP: equ 162FH +T17DONE: equ 1685H +T17END: equ 165FH +T17FAIL: equ 167FH +T17GOT: equ 15EDH +T17HALT: equ 167DH +T17LIM: equ 1612H +T17LOK: equ 1614H +T17MSG: equ 1980H +T17NL: equ 1640H +T17NXT: equ 1657H +T17RD: equ 15DDH +T17RW: equ 15C6H +T17WR: equ 1595H +T17WRD: equ 159EH +T1MSG: equ 1779H +T2MSG: equ 1795H +T3DONE: equ 128EH +T3FAIL: equ 1288H +T3MSG: equ 17B1H +T4DONE: equ 12D6H +T4FAIL: equ 12D0H +T4MSG: equ 17CDH +T5MSG: equ 17E9H +T6MSG: equ 1805H +T7MSG: equ 1821H +T8MSG: equ 183DH +T9MSG: equ 1859H +TOMSG: equ 19F9H diff --git a/projects/tzpuPico/src/z80.pio b/projects/tzpuPico/src/z80.pio index 008268d..a81148a 100644 --- a/projects/tzpuPico/src/z80.pio +++ b/projects/tzpuPico/src/z80.pio @@ -106,16 +106,21 @@ public start_addr: .wrap ; State machine to output an 8bit byte onto the Z80 Data Bus. -; The state machine sets IRQ 1 to indicate it is waiting, waits for the flag -; to clear then loads the 8bit data byte out of the fifo onto the pins. +; After driving data, waits for /WR to go active (low) then inactive (high), +; indicating the write cycle has completed, then tristates the data bus. +; This prevents stale data from persisting on the physical bus in virtual mode, +; where no subsequent physical bus cycle (M1 fetch / refresh) would otherwise +; clear it. Works with any number of wait states since it tracks the actual +; /WR signal rather than using a fixed timeout. .program z80_data .wrap_target public start_data: irq set 1 wait 0 irq 1 ; Wait till control starts the sequence. out pindirs, 8 ; Set direction to output (or input during tri-state BUSRQ). - out pins, 8 ; Output DATA (Z80_PIN_DATA_0-7) - wait 0 irq 0 ; Wait till next address change. + out pins, 8 ; Output DATA (Z80_PIN_DATA_0-7). + wait 0 gpio Z80_PIN_WR ; Wait for /WR active (low) — write cycle in progress. + wait 1 gpio Z80_PIN_WR ; Wait for /WR inactive (high) — write cycle complete. out pindirs, 8 ; Set final direction, normally input mode. .wrap diff --git a/projects/tzpuPico/tools/NetFileServer/netfs.py b/projects/tzpuPico/tools/NetFileServer/netfs.py new file mode 100755 index 0000000..9ba1da9 --- /dev/null +++ b/projects/tzpuPico/tools/NetFileServer/netfs.py @@ -0,0 +1,354 @@ +#!/usr/bin/env python3 +""" +NetFS — Binary File Server for Sharp MZ BASIC Network Drive + +Serves MZF files from a local directory over TCP using a simple binary protocol +designed for easy Z80 assembly parsing. + +Binary Protocol: + All requests start with 1-byte command. All responses start with 1-byte status. + Status: 0x00=OK, 0xFF=error, 0xFE=end of data. + + DIR (0x01): + Request: [0x01] [unit:1] + Response: [0x00] [count:1] [entry0:32] ... [entryN:32] + Each entry is 32 bytes: atrb(1) + name(17) + size(2LE) + dtadr(2LE) + exadr(2LE) + pad(8) + Returns up to 63 entries from the directory mapped to unit (1-7). Max 63 (2KB RX buffer). + + INFO (0x02): + Request: [0x02] [filename:17] + Response: [0x00] [entry:32] (or [0xFF] if not found) + + READ (0x03): + Request: [0x03] [unit:1] [filename:17] [offset:2LE] [length:2LE] + Response: [0x00] [actual_len:2LE] [data:actual_len] (or [0xFF] if not found) + + WRITE (0x04): + Request: [0x04] [unit:1] [filename:17] [atrb:1] [size:2LE] [dtadr:2LE] [exadr:2LE] [data:size] + Response: [0x00] (or [0xFF] on error) + + CLOSE (0x05): + Request: [0x05] + Response: [0x00] (server closes connection) + + DELETE (0x06): + Request: [0x06] [filename:17] + Response: [0x00] (or [0xFF] if not found) + + All commands include a unit byte (1-7) selecting the server directory. + +Usage: + python3 netfs.py [--port 6800] [--dir ./mzf_files] [--dir2 path] ... [--dir7 path] + + --dir maps to NET1: (or NET: with no digit), --dir2 maps to NET2:, etc. + Units without a --dirN argument fall back to --dir. + +(c) 2026 Philip Smart +""" + +import argparse +import os +import socket +import struct +import threading +import sys + +DEFAULT_PORT = 6800 +DEFAULT_DIR = "." +MZF_HEADER_SIZE = 128 + +# Protocol constants +CMD_DIR = 0x01 +CMD_INFO = 0x02 +CMD_READ = 0x03 +CMD_WRITE = 0x04 +CMD_CLOSE = 0x05 +CMD_DELETE = 0x06 +STATUS_OK = 0x00 +STATUS_ERR = 0xFF +STATUS_END = 0xFE + +def parse_mzf_header(data): + """Parse a 128-byte MZF header.""" + if len(data) < MZF_HEADER_SIZE: + return None + atrb = data[0] + # Remap RB/BSD/BRD types to BTX (type 2) so BASIC can LOAD them + if atrb in (0x03, 0x04, 0x05): + atrb = 0x02 + name_raw = data[1:18] + name = name_raw.rstrip(b'\x00\x0d').decode('ascii', errors='replace') + size = struct.unpack(' 0: + chunk = conn.recv(min(remaining, 4096)) + if not chunk: + break + remaining -= len(chunk) + conn.sendall(bytes([STATUS_ERR])) + name_str = name_bytes.rstrip(b'\x00\x0d').decode('ascii', errors='replace').strip() + print(f"[{addr}] WRITE unit={unit} '{name_str}' → unit not configured") + continue + # Read file data + data = b'' + remaining = size + while remaining > 0: + chunk = conn.recv(min(remaining, 4096)) + if not chunk: + break + data += chunk + remaining -= len(chunk) + # Build MZF file + name_str = name_bytes.rstrip(b'\x00\x0d').decode('ascii', errors='replace').strip() + fname = name_str.replace(' ', '_') + '.mzf' + fpath = os.path.join(dp, fname) + hdr = bytearray(MZF_HEADER_SIZE) + hdr[0] = atrb + hdr[1:18] = name_bytes[:17] + struct.pack_into('