Add serial upload tool for rapid Lua game iteration

Implements a complete serial upload workflow that allows uploading and
immediately testing Lua games via USB serial connection.

New Components:
- SerialUploader: Receives files via serial, writes to SD card
- upload_game.py: Python tool for sending files from host computer
- Protocol: Text-based with base64 encoding for reliability

Key Features:
- Uploads file to /games folder on SD card
- Overwrites existing files (FA_CREATE_ALWAYS)
- Auto-launches uploaded game immediately
- Proper memory cleanup (prevents Lua state conflicts)

SD Card Fixes:
- Fixed SPI speed management (12.5MHz for SD, 32MHz for display)
- Fixed SD write protocol (poll for data response token)
- Added speed switching wrappers around all FatFS operations
- Cleaned up excessive debug output

Game Launcher Improvements:
- Added clear_games() to prevent duplicate registrations
- Added cleanup in select_game_by_name() to delete old instances
- Added exact match priority in game selection
- LuaGameLoader now has clear_factory_data() for memory cleanup

Integration:
- Added serial_uploader to CMakeLists.txt
- Integrated into main loop in basic1.cpp
- Re-scans games after upload to pick up new files

Documentation:
- UPLOAD_TOOL.md: Usage instructions
- sd_card_best_practices.md: Critical lessons learned

Known Issues:
- Game launch after upload occasionally causes freeze (needs investigation)
- Display may not refresh properly after upload

Usage:
  python upload_game.py games/lua_examples/2048.lua /dev/tty.usbmodem101

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Adolfo Reyna
2026-02-12 22:52:57 -05:00
parent b26f3bf775
commit 84b009c33e
14 changed files with 1222 additions and 34 deletions

View File

@@ -8,6 +8,7 @@
#include "display/low_level_render.h"
#include "display/low_level_gui.h"
#include <stdio.h>
#include <cstring>
GameLauncher::GameLauncher(uint16_t width, uint16_t height,
LowLevelRenderer* renderer, LowLevelGUI* gui, InputManager* input_manager)
@@ -212,6 +213,72 @@ void GameLauncher::reset() {
printf("Launcher reset - returning to menu\n");
}
void GameLauncher::clear_games() {
// Clean up currently selected game first
if (selected_game) {
delete selected_game;
selected_game = nullptr;
}
// Clear the games vector
games.clear();
selected_index = 0;
current_page = 0;
printf("Launcher: Cleared all registered games\n");
}
bool GameLauncher::select_game_by_name(const char* name) {
// Clean up old game first if one exists
if (selected_game) {
printf("Cleaning up previous game...\n");
delete selected_game;
selected_game = nullptr;
}
// First pass: search for exact match (prefer exact matches)
for (size_t i = 0; i < games.size(); i++) {
if (strcmp(games[i].name, name) == 0) {
printf("Found exact match: %s\n", games[i].name);
// Create and initialize the game
selected_game = games[i].factory(width, height, renderer, gui, input_manager);
if (selected_game) {
printf("Game instance created, initializing...\n");
selected_game->init();
selected_index = i;
current_page = i / GAMES_PER_PAGE;
return true;
} else {
printf("Failed to create game instance\n");
return false;
}
}
}
// Second pass: search in reverse order for partial match (newest files first)
for (int i = games.size() - 1; i >= 0; i--) {
if (strstr(games[i].name, name) != nullptr) {
printf("Found partial match: %s (searching for: %s)\n", games[i].name, name);
// Create and initialize the game
selected_game = games[i].factory(width, height, renderer, gui, input_manager);
if (selected_game) {
printf("Game instance created, initializing...\n");
selected_game->init();
selected_index = i;
current_page = i / GAMES_PER_PAGE;
return true;
} else {
printf("Failed to create game instance\n");
return false;
}
}
}
printf("Game not found: %s\n", name);
return false;
}
int GameLauncher::get_total_pages() const {
if (games.empty()) return 1;
return (games.size() + GAMES_PER_PAGE - 1) / GAMES_PER_PAGE;

View File

@@ -78,13 +78,25 @@ public:
* @brief Reset launcher to show menu again
*/
void reset();
/**
* @brief Clear all registered games (useful before re-scanning)
*/
void clear_games();
/**
* @brief Check if a game is currently selected
* @return true if game selected, false if in menu
*/
bool is_game_selected() const { return selected_game != nullptr; }
/**
* @brief Select a game by name (for programmatic launching)
* @param name Game name to select (partial match supported)
* @return true if game was found and launched, false otherwise
*/
bool select_game_by_name(const char* name);
private:
uint16_t width;
uint16_t height;

View File

@@ -323,47 +323,69 @@ bool sd_card_read_blocks(uint32_t block_addr, uint32_t num_blocks, uint8_t *buff
}
bool sd_card_write_block(uint32_t block_addr, const uint8_t *buffer) {
if (!g_card_info.initialized || buffer == NULL) return false;
if (!g_card_info.initialized || buffer == NULL) {
return false;
}
// For non-SDHC cards, convert block address to byte address
if (g_card_info.type != SD_CARD_TYPE_SDHC) {
block_addr *= SD_BLOCK_SIZE;
}
sd_card_select();
// Send write command
uint8_t r1 = sd_card_send_command(SD_CMD24, block_addr);
if (r1 != SD_R1_READY) {
sd_card_deselect();
return false;
}
// Wait for card to be ready to receive data
uint32_t timeout_count = 0;
uint8_t ready_byte;
do {
ready_byte = sd_card_transfer(0xFF);
timeout_count++;
if (timeout_count > 1000) {
sd_card_deselect();
return false;
}
} while (ready_byte != 0xFF);
// Send start token
sd_card_transfer(SD_START_TOKEN);
// Write data block
for (int i = 0; i < SD_BLOCK_SIZE; i++) {
sd_card_transfer(buffer[i]);
}
// Send dummy CRC (2 bytes)
sd_card_transfer(0xFF);
sd_card_transfer(0xFF);
// Check data response
uint8_t response = sd_card_transfer(0xFF);
// Check data response - may need to read several bytes before getting response
uint8_t response = 0xFF;
for (int i = 0; i < 10; i++) {
response = sd_card_transfer(0xFF);
if (response != 0xFF) {
break;
}
}
if ((response & 0x1F) != SD_DATA_ACCEPTED) {
sd_card_deselect();
return false;
}
// Wait for card to finish writing
if (!sd_card_wait_ready(500)) {
sd_card_deselect();
return false;
}
sd_card_deselect();
return true;
}

321
lib/serial_uploader.cpp Normal file
View File

@@ -0,0 +1,321 @@
#include "serial_uploader.h"
#include "game_launcher.h"
#include "lua_game_loader.h"
#include "sd_card.h"
#include "pico/stdlib.h"
#include <cstring>
#include <cstdio>
#include <cctype>
// Maximum file size: 64KB (should be plenty for Lua games)
#define MAX_FILE_SIZE (64 * 1024)
SerialUploader::SerialUploader(GameLauncher* launcher)
: state(IDLE)
, game_launcher(launcher)
, file_size(0)
, bytes_received(0)
, file_buffer(nullptr)
, base64_index(0)
{
filename[0] = '\0';
last_uploaded_name[0] = '\0';
}
void SerialUploader::reset() {
state = IDLE;
file_size = 0;
bytes_received = 0;
base64_index = 0;
filename[0] = '\0';
if (file_buffer) {
delete[] file_buffer;
file_buffer = nullptr;
}
}
uint8_t SerialUploader::decode_base64_char(char c) {
if (c >= 'A' && c <= 'Z') return c - 'A';
if (c >= 'a' && c <= 'z') return c - 'a' + 26;
if (c >= '0' && c <= '9') return c - '0' + 52;
if (c == '+') return 62;
if (c == '/') return 63;
return 0;
}
void SerialUploader::decode_base64_block(const char* input, uint8_t* output) {
uint32_t combined = (decode_base64_char(input[0]) << 18) |
(decode_base64_char(input[1]) << 12) |
(decode_base64_char(input[2]) << 6) |
decode_base64_char(input[3]);
output[0] = (combined >> 16) & 0xFF;
output[1] = (combined >> 8) & 0xFF;
output[2] = combined & 0xFF;
}
bool SerialUploader::write_file_to_sd() {
if (bytes_received == 0) {
printf("ERROR No data received!\n");
return false;
}
if (!file_buffer) {
printf("ERROR File buffer is NULL!\n");
return false;
}
// Set SPI speed to SD card speed (12.5 MHz) and save previous speed
uint prev_speed = sd_card_set_spi_speed();
// Ensure /games directory exists
FRESULT fr = f_mkdir("/games");
if (fr != FR_OK && fr != FR_EXIST) {
printf("ERROR Failed to create /games directory: %d\n", fr);
// Try to continue anyway in case it already exists
}
// Build file path - overwrite if file exists
char filepath[128];
snprintf(filepath, sizeof(filepath), "/games/%s", filename);
FIL fil;
fr = f_open(&fil, filepath, FA_CREATE_ALWAYS | FA_WRITE);
if (fr != FR_OK) {
printf("ERROR Failed to open file for writing: %d\n", fr);
sd_card_restore_spi_speed(prev_speed);
return false;
}
// Write data in chunks (more reliable for large files)
const uint32_t CHUNK_SIZE = 512;
uint32_t total_written = 0;
while (total_written < bytes_received) {
uint32_t chunk = (bytes_received - total_written > CHUNK_SIZE) ? CHUNK_SIZE : (bytes_received - total_written);
UINT bytes_written = 0;
fr = f_write(&fil, file_buffer + total_written, chunk, &bytes_written);
if (fr != FR_OK) {
printf("ERROR f_write failed at byte %u: error %d\n", total_written, fr);
f_close(&fil);
sd_card_restore_spi_speed(prev_speed);
return false;
}
if (bytes_written != chunk) {
printf("ERROR Partial write: wrote %u/%u bytes at offset %u\n", bytes_written, chunk, total_written);
f_close(&fil);
sd_card_restore_spi_speed(prev_speed);
return false;
}
total_written += bytes_written;
}
// Sync to ensure data is written to SD card
fr = f_sync(&fil);
if (fr != FR_OK) {
printf("ERROR f_sync failed: %d\n", fr);
}
f_close(&fil);
printf("✓ Wrote %u bytes to %s\n", total_written, filepath);
// Restore display SPI speed
sd_card_restore_spi_speed(prev_speed);
return true;
}
void SerialUploader::launch_game() {
// Extract base game name (remove .lua extension)
strncpy(last_uploaded_name, filename, sizeof(last_uploaded_name) - 1);
last_uploaded_name[sizeof(last_uploaded_name) - 1] = '\0';
// Remove .lua extension
char* ext = strstr(last_uploaded_name, ".lua");
if (ext) {
*ext = '\0';
}
printf("Re-scanning Lua games to pick up new file...\n");
// Clear existing games before re-scanning (prevents duplicates and memory leaks)
game_launcher->clear_games();
LuaGameLoader::clear_factory_data();
// Re-scan Lua games to pick up the newly uploaded file
// Note: LuaGameLoader::register_all_games handles SPI speed internally
int new_games = LuaGameLoader::register_all_games(game_launcher);
printf("Found %d Lua games after re-scan\n", new_games);
// Now try to launch the newly uploaded game by name
printf("Attempting to launch game: %s\n", last_uploaded_name);
bool launched = game_launcher->select_game_by_name(last_uploaded_name);
if (launched) {
printf("LAUNCHED %s\n", last_uploaded_name);
} else {
printf("ERROR Failed to launch game: %s\n", last_uploaded_name);
}
}
bool SerialUploader::process() {
if (state == IDLE) {
// Check for "UPLOAD" command
int c = getchar_timeout_us(0);
if (c == PICO_ERROR_TIMEOUT) return false;
if (c == 'U') {
// Check for "UPLOAD "
static char cmd_buffer[8];
cmd_buffer[0] = c;
int idx = 1;
// Read "PLOAD "
for (int i = 0; i < 6; i++) {
c = getchar_timeout_us(100000); // 100ms timeout
if (c == PICO_ERROR_TIMEOUT) return false;
cmd_buffer[idx++] = c;
}
cmd_buffer[idx] = '\0';
if (strcmp(cmd_buffer, "UPLOAD ") == 0) {
state = RECEIVING_FILENAME;
filename[0] = '\0';
return false;
}
}
}
if (state == RECEIVING_FILENAME) {
// Read filename until space
int idx = strlen(filename);
while (idx < sizeof(filename) - 1) {
int c = getchar_timeout_us(100000);
if (c == PICO_ERROR_TIMEOUT) break;
if (c == ' ') {
filename[idx] = '\0';
state = RECEIVING_SIZE;
file_size = 0;
return false;
}
filename[idx++] = (char)c;
}
}
if (state == RECEIVING_SIZE) {
// Read file size until newline
char size_buffer[16];
int idx = 0;
while (idx < sizeof(size_buffer) - 1) {
int c = getchar_timeout_us(100000);
if (c == PICO_ERROR_TIMEOUT) break;
if (c == '\n' || c == '\r') {
size_buffer[idx] = '\0';
file_size = atoi(size_buffer);
if (file_size == 0 || file_size > MAX_FILE_SIZE) {
printf("ERROR Invalid file size: %u\n", file_size);
reset();
return false;
}
// Allocate buffer for decoded data
// file_size is the ORIGINAL (decoded) size, so allocate exactly that
file_buffer = new uint8_t[file_size];
if (!file_buffer) {
printf("ERROR Failed to allocate %u bytes for file buffer\n", file_size);
reset();
return false;
}
bytes_received = 0;
base64_index = 0;
state = RECEIVING_DATA;
printf("Receiving %u bytes for %s...\n", file_size, filename);
return false;
}
size_buffer[idx++] = (char)c;
}
}
if (state == RECEIVING_DATA) {
// Read base64 data until "END"
while (true) {
int c = getchar_timeout_us(1000);
if (c == PICO_ERROR_TIMEOUT) break;
// Check for "END" marker
static char end_check[4] = {0, 0, 0, 0};
end_check[0] = end_check[1];
end_check[1] = end_check[2];
end_check[2] = end_check[3];
end_check[3] = (char)c;
if (end_check[0] == '\n' && end_check[1] == 'E' && end_check[2] == 'N' && end_check[3] == 'D') {
state = WRITING_FILE;
return false;
}
// Skip whitespace
if (isspace(c)) continue;
// Accumulate base64 characters
if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') || c == '+' || c == '/' || c == '=') {
base64_buffer[base64_index++] = (char)c;
// Decode every 4 characters
if (base64_index == 4) {
uint8_t decoded[3];
decode_base64_block(base64_buffer, decoded);
// Handle padding
int decoded_count = 3;
if (base64_buffer[2] == '=') decoded_count = 1;
else if (base64_buffer[3] == '=') decoded_count = 2;
// Copy decoded bytes
for (int i = 0; i < decoded_count; i++) {
if (bytes_received < file_size) {
file_buffer[bytes_received++] = decoded[i];
}
}
base64_index = 0;
}
}
}
}
if (state == WRITING_FILE) {
if (write_file_to_sd()) {
state = LAUNCHING_GAME;
} else {
reset();
}
return false;
}
if (state == LAUNCHING_GAME) {
launch_game();
reset();
return true; // Signal that game was launched
}
return false;
}

52
lib/serial_uploader.h Normal file
View File

@@ -0,0 +1,52 @@
#ifndef SERIAL_UPLOADER_H
#define SERIAL_UPLOADER_H
#include <cstdint>
#include "ff.h"
class GameLauncher;
class SerialUploader {
public:
SerialUploader(GameLauncher* launcher);
// Process incoming serial data (call this frequently in main loop)
// Returns true if a game was launched
bool process();
// Get the filename of the last uploaded game (without .lua extension)
const char* get_last_uploaded_filename() const { return last_uploaded_name; }
private:
enum State {
IDLE,
RECEIVING_FILENAME,
RECEIVING_SIZE,
RECEIVING_DATA,
WRITING_FILE,
LAUNCHING_GAME
};
State state;
GameLauncher* game_launcher;
// Upload state
char filename[64];
char last_uploaded_name[64]; // Game name without .lua extension
uint32_t file_size;
uint32_t bytes_received;
uint8_t* file_buffer;
// Base64 decoding buffer
char base64_buffer[4];
int base64_index;
// Helper methods
void reset();
bool write_file_to_sd();
void launch_game();
uint8_t decode_base64_char(char c);
void decode_base64_block(const char* input, uint8_t* output);
};
#endif // SERIAL_UPLOADER_H