diff --git a/rctctl/CMakeLists.txt b/rctctl/CMakeLists.txt index 6e38eae9d414..a45af46db4df 100644 --- a/rctctl/CMakeLists.txt +++ b/rctctl/CMakeLists.txt @@ -1,3 +1,14 @@ +cmake_minimum_required(VERSION 3.14) +project(rctctl) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +# When built standalone, set ROOT_DIR to parent directory +if (NOT DEFINED ROOT_DIR) + get_filename_component(ROOT_DIR "${CMAKE_CURRENT_LIST_DIR}/.." ABSOLUTE) +endif() + set(RCTCTL_SOURCES "${CMAKE_CURRENT_LIST_DIR}/src/main.cpp" "${CMAKE_CURRENT_LIST_DIR}/src/cli/cli.cpp" @@ -43,6 +54,7 @@ endif() list(APPEND RCTCTL_JSON_HINTS "$ENV{NLOHMANN_JSON_INCLUDE_DIR}" "${ROOT_DIR}/lib/macos/include" + "${ROOT_DIR}/lib/x64/include" "/usr/include" "/usr/local/include" ) diff --git a/src/openrct2-ui/libopenrct2ui.vcxproj b/src/openrct2-ui/libopenrct2ui.vcxproj index d207fcb5b261..b7a1a930374a 100644 --- a/src/openrct2-ui/libopenrct2ui.vcxproj +++ b/src/openrct2-ui/libopenrct2ui.vcxproj @@ -103,6 +103,7 @@ + @@ -162,6 +163,7 @@ + diff --git a/src/openrct2-ui/windows/AIAgentTerminal.cpp b/src/openrct2-ui/windows/AIAgentTerminal.cpp index b57adba8b8cf..b7dd7b741725 100644 --- a/src/openrct2-ui/windows/AIAgentTerminal.cpp +++ b/src/openrct2-ui/windows/AIAgentTerminal.cpp @@ -23,6 +23,23 @@ #include #include +#if defined(_WIN32) + #ifndef WIN32_LEAN_AND_MEAN + #define WIN32_LEAN_AND_MEAN + #endif + #ifndef NOMINMAX + #define NOMINMAX + #endif + #include + // Undefine Windows macros that conflict with OpenRCT2 code + #ifdef CreateWindow + #undef CreateWindow + #endif + #ifdef DrawText + #undef DrawText + #endif +#endif + #include #include #include @@ -697,6 +714,10 @@ namespace OpenRCT2::Ui::Windows bool _launchAttempted = false; bool _textCaptureActive = false; bool _lastProcessRunning = false; +#if defined(_WIN32) + void* _externalProcessHandle = nullptr; // HANDLE for external Claude process + bool _externalLaunchActive = false; +#endif bool _needsFullRedraw = true; uint64_t _lastOutputTimestamp = 0; int32_t _cellWidth = 8; @@ -799,8 +820,8 @@ namespace OpenRCT2::Ui::Windows void DrawTerminalToBuffer(RenderTarget& rt, int32_t canvasWidth, int32_t canvasHeight, const std::vector& lines) const; void DrawTerminalDirty(RenderTarget& rt, int32_t canvasWidth, int32_t canvasHeight, const std::vector& cells, const std::vector& lines); - void EnsureOffscreenBuffer(int32_t width, int32_t height); - void BlitOffscreenToScreen(RenderTarget& screenRT, const ScreenCoordsXY& destPos, int32_t width, int32_t height); + void EnsureOffscreenBuffer(int32_t bufWidth, int32_t bufHeight); + void BlitOffscreenToScreen(RenderTarget& screenRT, const ScreenCoordsXY& destPos, int32_t blitWidth, int32_t blitHeight); void DrawCells(RenderTarget& rt, const ScreenCoordsXY& origin, int32_t canvasWidth, int32_t canvasHeight) const; void DrawRowCells(RenderTarget& rt, const ScreenCoordsXY& rowOrigin, std::span row) const; void DrawCellAt(RenderTarget& rt, const ScreenCoordsXY& rowOrigin, int32_t col, const TerminalCell& cell) const; @@ -880,7 +901,11 @@ namespace OpenRCT2::Ui::Windows } // Initialize workspace path for session logging +#if defined(_WIN32) + const char* home = std::getenv("USERPROFILE"); +#else const char* home = std::getenv("HOME"); +#endif if (home && *home) { _workspacePath = std::filesystem::path(home) / ".openrct2-agent"; @@ -955,8 +980,24 @@ namespace OpenRCT2::Ui::Windows sAIAgentTerminalInstance = nullptr; } - // Generate HTML session log before closing - GenerateSessionLog(); +#if defined(_WIN32) + // Terminate external Claude process if running + if (_externalLaunchActive && _externalProcessHandle != nullptr) + { + TerminateProcess(static_cast(_externalProcessHandle), 0); + CloseHandle(static_cast(_externalProcessHandle)); + _externalProcessHandle = nullptr; + _externalLaunchActive = false; + } +#endif + + // Generate HTML session log before closing (skip if external launch - no terminal session) +#if defined(_WIN32) + if (!_externalLaunchActive) +#endif + { + GenerateSessionLog(); + } // Release viewport lock so manual panning works after terminal closes SetViewportLock(false); @@ -1006,9 +1047,9 @@ namespace OpenRCT2::Ui::Windows const int16_t targetWidth = collapsed ? GetCollapsedWidth() : std::clamp(_restoreWidth, kAgentWindowSize.width, kAgentWindowMaxSize.width); - const int16_t minHeight = kAgentWindowSize.height; - const int16_t maxHeight = kAgentWindowMaxSize.height; - const int16_t targetHeight = std::clamp(height, minHeight, maxHeight); + const int16_t minH = kAgentWindowSize.height; + const int16_t maxH = kAgentWindowMaxSize.height; + const int16_t targetHeight = std::clamp(height, minH, maxH); const int16_t dw = static_cast(targetWidth - width); const int16_t dh = static_cast(targetHeight - height); @@ -1071,6 +1112,139 @@ namespace OpenRCT2::Ui::Windows return; } + LOG_INFO("AIAgentTerminal: Launching with command: %s", + _launchPlan.options.command.empty() ? "(empty)" : _launchPlan.options.command[0].c_str()); + +#if defined(_WIN32) + // On Windows, launch Claude in a separate console window for proper terminal support + if (_launchPlan.launchExternal && !_launchPlan.options.command.empty()) + { + // Build command line string + std::string cmdLine; + for (size_t i = 0; i < _launchPlan.options.command.size(); i++) + { + if (i > 0) cmdLine += " "; + // Quote arguments that contain spaces + const auto& arg = _launchPlan.options.command[i]; + if (arg.find(' ') != std::string::npos) + { + cmdLine += "\"" + arg + "\""; + } + else + { + cmdLine += arg; + } + } + + // Convert to wide string for CreateProcessW + std::wstring wCmdLine(cmdLine.begin(), cmdLine.end()); + std::wstring wWorkDir; + if (!_launchPlan.options.workingDirectory.empty()) + { + wWorkDir = std::wstring(_launchPlan.options.workingDirectory.begin(), + _launchPlan.options.workingDirectory.end()); + } + + // Build environment block - inherit current environment and add custom vars + std::wstring envBlock; + wchar_t* currentEnv = GetEnvironmentStringsW(); + if (currentEnv != nullptr) + { + // Copy current environment (each var is null-terminated, block ends with double null) + const wchar_t* ptr = currentEnv; + while (*ptr != L'\0') + { + size_t len = wcslen(ptr); + // Skip any existing vars that we're overriding + std::wstring varW(ptr, len); + bool skip = false; + for (const auto& customVar : _launchPlan.options.environment) + { + size_t eqPos = customVar.find('='); + if (eqPos != std::string::npos) + { + std::string varName = customVar.substr(0, eqPos + 1); + std::wstring varNameW(varName.begin(), varName.end()); + if (varW.substr(0, varNameW.size()) == varNameW) + { + skip = true; + break; + } + } + } + if (!skip) + { + envBlock.append(ptr, len); + envBlock += L'\0'; + } + ptr += len + 1; + } + FreeEnvironmentStringsW(currentEnv); + } + // Add custom environment variables + for (const auto& env : _launchPlan.options.environment) + { + std::wstring wEnv(env.begin(), env.end()); + envBlock += wEnv; + envBlock += L'\0'; + } + envBlock += L'\0'; // Double null terminator + + STARTUPINFOW si = {}; + si.cb = sizeof(si); + si.dwFlags = STARTF_USESHOWWINDOW; + si.wShowWindow = SW_SHOW; + PROCESS_INFORMATION pi = {}; + + BOOL success = CreateProcessW( + nullptr, + wCmdLine.data(), + nullptr, + nullptr, + FALSE, + CREATE_NEW_CONSOLE | CREATE_UNICODE_ENVIRONMENT, + envBlock.data(), + wWorkDir.empty() ? nullptr : wWorkDir.c_str(), + &si, + &pi + ); + + if (success) + { + // Store process handle for monitoring + _externalProcessHandle = pi.hProcess; + CloseHandle(pi.hThread); + + // Create a Job Object to automatically terminate Claude when the game exits + HANDLE hJob = CreateJobObjectW(nullptr, nullptr); + if (hJob != nullptr) + { + JOBOBJECT_EXTENDED_LIMIT_INFORMATION jobInfo = {}; + jobInfo.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + SetInformationJobObject(hJob, JobObjectExtendedLimitInformation, &jobInfo, sizeof(jobInfo)); + AssignProcessToJobObject(hJob, pi.hProcess); + // Don't close hJob - keep it open so the job stays active + // It will be cleaned up when our process exits + } + + LOG_INFO("AIAgentTerminal: Claude launched in external console window"); + _statusMessage = "Claude running in external console window"; + _errorMessage.clear(); + _externalLaunchActive = true; + } + else + { + DWORD err = GetLastError(); + _errorMessage = "Failed to launch Claude: error " + std::to_string(err); + LOG_WARNING("AIAgentTerminal: External launch failed: %s", _errorMessage.c_str()); + } + + _needsFullRedraw = true; + invalidateWidget(WIDX_TERMINAL_CANVAS); + return; + } +#endif + std::string error; _shellProcess = LaunchShellProcess(_launchPlan.options, error); if (!_shellProcess) @@ -1081,6 +1255,7 @@ namespace OpenRCT2::Ui::Windows return; } + LOG_INFO("AIAgentTerminal: Shell process created successfully"); _statusMessage = "Running: " + _launchPlan.description; _errorMessage.clear(); _needsFullRedraw = true; @@ -1573,6 +1748,10 @@ namespace OpenRCT2::Ui::Windows if (totalRead > 0) { + if (!_hasSeenOutput) + { + LOG_INFO("AIAgentTerminal: First output received (%zu bytes)", totalRead); + } EnsureSession(); UpdateSynchronizedUpdateState({ _ptyDrainBuffer.data(), totalRead }); _terminalSession->FeedOutput({ _ptyDrainBuffer.data(), totalRead }); @@ -1873,6 +2052,8 @@ namespace OpenRCT2::Ui::Windows _snapshot = std::move(_pendingSnapshot); _hasSnapshot = true; _hasPendingSnapshot = false; + LOG_INFO("AIAgentTerminal: Snapshot updated, rows=%d cols=%d cells=%zu", + _snapshot.rows, _snapshot.cols, _snapshot.cells.size()); // Auto-scroll to tail if: // 1. Scroll lock is enabled (force stay at bottom), OR @@ -2129,33 +2310,42 @@ namespace OpenRCT2::Ui::Windows return { cols, rows, widthPx, heightPx }; } - void AIAgentTerminalWindow::EnsureOffscreenBuffer(int32_t width, int32_t height) + void AIAgentTerminalWindow::EnsureOffscreenBuffer(int32_t bufWidth, int32_t bufHeight) { - if (_offscreenWidth == width && _offscreenHeight == height && _offscreenBuffer) + if (_offscreenWidth == bufWidth && _offscreenHeight == bufHeight && _offscreenBuffer) return; // Allocate new buffer - const size_t bufferSize = static_cast(width) * height; + const size_t bufferSize = static_cast(bufWidth) * bufHeight; _offscreenBuffer = std::make_unique(bufferSize); - _offscreenWidth = width; - _offscreenHeight = height; + _offscreenWidth = bufWidth; + _offscreenHeight = bufHeight; // Clear to background color std::fill_n(_offscreenBuffer.get(), bufferSize, ColourMapA[COLOUR_BLACK].mid_dark); } void AIAgentTerminalWindow::BlitOffscreenToScreen( - RenderTarget& screenRT, const ScreenCoordsXY& destPos, int32_t width, int32_t height) + RenderTarget& screenRT, const ScreenCoordsXY& destPos, int32_t blitWidth, int32_t blitHeight) { - if (!_offscreenBuffer || width <= 0 || height <= 0) + if (!_offscreenBuffer || blitWidth <= 0 || blitHeight <= 0) + { + static bool sWarnedOnce = false; + if (!sWarnedOnce) + { + LOG_WARNING("AIAgentTerminal: BlitOffscreenToScreen skipped - buffer=%p, w=%d, h=%d", + static_cast(_offscreenBuffer.get()), blitWidth, blitHeight); + sWarnedOnce = true; + } return; + } // Convert screen coordinates to buffer-relative coordinates // (same transformation that FillRect uses internally) int32_t srcX = 0, srcY = 0; int32_t dstX = destPos.x - screenRT.x; int32_t dstY = destPos.y - screenRT.y; - int32_t copyWidth = width, copyHeight = height; + int32_t copyWidth = blitWidth, copyHeight = blitHeight; // Clip left edge if (dstX < 0) @@ -2201,6 +2391,15 @@ namespace OpenRCT2::Ui::Windows void AIAgentTerminalWindow::DrawTerminalDoubleBuffered(RenderTarget& screenRT, const Widget& widget) { + static int sDrawCount = 0; + sDrawCount++; + if (sDrawCount <= 5 || (sDrawCount % 100 == 0)) + { + LOG_INFO("AIAgentTerminal: Draw #%d, _hasSnapshot=%d, _terminalFontReady=%d, offscreen=%p", + sDrawCount, _hasSnapshot ? 1 : 0, _terminalFontReady ? 1 : 0, + static_cast(_offscreenBuffer.get())); + } + // Use full widget size (including padding) to avoid unfilled gaps. // Widget coordinates are inclusive, so add 1 for actual pixel dimensions. const int32_t widgetWidth = widget.right - widget.left + 1; @@ -2247,26 +2446,95 @@ namespace OpenRCT2::Ui::Windows forceFullRedraw = (_lastOverlayVisible != overlayVisible) || (_lastOverlayHeightPx != overlayHeightPx); } - if (forceFullRedraw) - { - // Draw terminal content to offscreen buffer (includes padding fill) - DrawTerminalToBuffer(offscreenRT, widgetWidth, widgetHeight, lines); - _renderedCells = std::move(currentCells); - _renderedCols = _hasSnapshot ? _snapshot.cols : 0; - _renderedRows = _hasSnapshot ? _visibleRows : 0; - _needsFullRedraw = false; - } - else + // Draw directly to screen (bypasses offscreen buffer which has issues on Windows) + ScreenCoordsXY widgetScreenPos = windowPos + ScreenCoordsXY{ widget.left, widget.top }; + + // Draw background + ScreenCoordsXY bgTopLeft = widgetScreenPos; + ScreenCoordsXY bgBottomRight = widgetScreenPos + ScreenCoordsXY{ widgetWidth - 1, widgetHeight - 1 }; + Rect::fill(screenRT, { bgTopLeft, bgBottomRight }, ColourMapA[COLOUR_BLACK].mid_dark); + + // Draw cells if we have a snapshot - use native DrawText for proper UTF-8 support + if (_hasSnapshot && !_snapshot.cells.empty()) { - DrawTerminalDirty(offscreenRT, widgetWidth, widgetHeight, currentCells, lines); + const int32_t cols = _snapshot.cols; + const int32_t rows = std::min(_visibleRows, _snapshot.rows); + ScreenCoordsXY contentOrigin = widgetScreenPos + ScreenCoordsXY{ kTerminalPadding, kTerminalPadding }; + + // Build and draw each row as a single string using native text rendering + for (int32_t row = 0; row < rows; row++) + { + // Build UTF-8 string for this row + std::string rowText; + rowText.reserve(static_cast(cols) * 4); // Reserve for worst-case UTF-8 + + for (int32_t col = 0; col < cols; col++) + { + const size_t idx = static_cast(row) * cols + col; + if (idx >= _snapshot.cells.size()) + break; + + const auto& cell = _snapshot.cells[idx]; + + // Skip continuation cells (part of wide characters) + if (cell.continuation) + continue; + + // Convert codepoint to UTF-8, substituting box drawing chars with ASCII + char32_t cp = cell.codepoint; + if (cp > 0 && cp != U' ') + { + // Substitute box drawing characters (U+2500-U+257F) with ASCII + if (cp >= 0x2500 && cp <= 0x257F) + { + // Horizontal lines + if (cp == 0x2500 || cp == 0x2501 || cp == 0x2504 || cp == 0x2505 || + cp == 0x2508 || cp == 0x2509 || cp == 0x254C || cp == 0x254D) + cp = '-'; + // Vertical lines + else if (cp == 0x2502 || cp == 0x2503 || cp == 0x2506 || cp == 0x2507 || + cp == 0x250A || cp == 0x250B || cp == 0x254E || cp == 0x254F) + cp = '|'; + // Corners and intersections become + + else + cp = '+'; + } + // Block elements (U+2580-U+259F) - use # or space + else if (cp >= 0x2580 && cp <= 0x259F) + { + cp = '#'; + } + + char buffer[8] = {}; + char* end = UTF8WriteCodepoint(buffer, static_cast(cp)); + rowText.append(buffer, end - buffer); + } + else + { + rowText.push_back(' '); + } + } + + // Trim trailing spaces for cleaner rendering + while (!rowText.empty() && rowText.back() == ' ') + rowText.pop_back(); + + // Draw the row using native text rendering + if (!rowText.empty()) + { + ScreenCoordsXY rowPos = contentOrigin + ScreenCoordsXY{ 0, row * _cellHeight }; + DrawText(screenRT, rowPos, { COLOUR_WHITE, FontStyle::medium }, rowText.c_str(), true); + } + } } + // Draw status overlay + ScreenCoordsXY overlayOrigin = widgetScreenPos + ScreenCoordsXY{ kTerminalPadding, kTerminalPadding }; + DrawStatusOverlay(screenRT, overlayOrigin, contentWidth, contentHeight, lines); + _lastOverlayVisible = overlayVisible; _lastOverlayHeightPx = overlayHeightPx; - - // Blit the complete widget frame to screen in one operation - ScreenCoordsXY destPos = windowPos + ScreenCoordsXY{ widget.left, widget.top }; - BlitOffscreenToScreen(screenRT, destPos, widgetWidth, widgetHeight); + _needsFullRedraw = false; } void AIAgentTerminalWindow::DrawTerminalToBuffer( @@ -2879,7 +3147,11 @@ namespace OpenRCT2::Ui::Windows _autoplayPromptIndex = 0; // Try to load from workspace auto_prompts.txt +#if defined(_WIN32) + const char* home = std::getenv("USERPROFILE"); +#else const char* home = std::getenv("HOME"); +#endif if (!home || !*home) { return; @@ -3108,12 +3380,21 @@ namespace OpenRCT2::Ui::Windows void AIAgentTerminalWindow::InitializeSessionMonitor() { // Get the workspace path that Claude is running in +#if defined(_WIN32) + const char* home = std::getenv("USERPROFILE"); + if (!home || !*home) + { + LOG_WARNING("Auto-play: Cannot initialize session monitor - USERPROFILE not set"); + return; + } +#else const char* home = std::getenv("HOME"); if (!home || !*home) { LOG_WARNING("Auto-play: Cannot initialize session monitor - HOME not set"); return; } +#endif auto workspacePath = std::filesystem::path(home) / ".openrct2-agent"; // Create session monitor to watch Claude's JSONL session files @@ -3212,9 +3493,131 @@ namespace OpenRCT2::Ui::Windows WindowBase* AIAgentTerminalOpen() { +#if defined(_WIN32) + // On Windows, launch Claude in a separate console window without creating an in-game terminal + auto plan = BuildAIAgentLaunchPlan(80, 24); + if (!plan.available || !plan.launchExternal || plan.options.command.empty()) + { + // Fall back to in-game window if external launch not available + auto* windowMgr = GetWindowManager(); + return windowMgr->FocusOrCreate( + WindowClass::aiAgentTerminal, kAgentWindowSize, + { WindowFlag::resizable, WindowFlag::autoPosition, WindowFlag::higherContrastOnPress, WindowFlag::noPush }); + } + + // Build command line string + std::string cmdLine; + for (size_t i = 0; i < plan.options.command.size(); i++) + { + if (i > 0) cmdLine += " "; + const auto& arg = plan.options.command[i]; + if (arg.find(' ') != std::string::npos) + { + cmdLine += "\"" + arg + "\""; + } + else + { + cmdLine += arg; + } + } + + std::wstring wCmdLine(cmdLine.begin(), cmdLine.end()); + std::wstring wWorkDir; + if (!plan.options.workingDirectory.empty()) + { + wWorkDir = std::wstring(plan.options.workingDirectory.begin(), + plan.options.workingDirectory.end()); + } + + // Build environment block + std::wstring envBlock; + wchar_t* currentEnv = GetEnvironmentStringsW(); + if (currentEnv != nullptr) + { + const wchar_t* ptr = currentEnv; + while (*ptr != L'\0') + { + size_t len = wcslen(ptr); + std::wstring varW(ptr, len); + bool skip = false; + for (const auto& customVar : plan.options.environment) + { + size_t eqPos = customVar.find('='); + if (eqPos != std::string::npos) + { + std::string varName = customVar.substr(0, eqPos + 1); + std::wstring varNameW(varName.begin(), varName.end()); + if (varW.substr(0, varNameW.size()) == varNameW) + { + skip = true; + break; + } + } + } + if (!skip) + { + envBlock.append(ptr, len); + envBlock += L'\0'; + } + ptr += len + 1; + } + FreeEnvironmentStringsW(currentEnv); + } + for (const auto& env : plan.options.environment) + { + std::wstring wEnv(env.begin(), env.end()); + envBlock += wEnv; + envBlock += L'\0'; + } + envBlock += L'\0'; + + STARTUPINFOW si = {}; + si.cb = sizeof(si); + si.dwFlags = STARTF_USESHOWWINDOW; + si.wShowWindow = SW_SHOW; + PROCESS_INFORMATION pi = {}; + + BOOL success = CreateProcessW( + nullptr, + wCmdLine.data(), + nullptr, + nullptr, + FALSE, + CREATE_NEW_CONSOLE | CREATE_UNICODE_ENVIRONMENT, + envBlock.data(), + wWorkDir.empty() ? nullptr : wWorkDir.c_str(), + &si, + &pi + ); + + if (success) + { + CloseHandle(pi.hThread); + + // Create Job Object to auto-terminate Claude when game exits + HANDLE hJob = CreateJobObjectW(nullptr, nullptr); + if (hJob != nullptr) + { + JOBOBJECT_EXTENDED_LIMIT_INFORMATION jobInfo = {}; + jobInfo.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + SetInformationJobObject(hJob, JobObjectExtendedLimitInformation, &jobInfo, sizeof(jobInfo)); + AssignProcessToJobObject(hJob, pi.hProcess); + } + + LOG_INFO("AIAgentTerminal: Claude launched in external console window (no in-game window)"); + CloseHandle(pi.hProcess); + } + else + { + LOG_WARNING("AIAgentTerminal: Failed to launch Claude externally: %lu", GetLastError()); + } + + return nullptr; // No in-game window +#else auto* windowMgr = GetWindowManager(); return windowMgr->FocusOrCreate( WindowClass::aiAgentTerminal, kAgentWindowSize, { WindowFlag::resizable, WindowFlag::autoPosition, WindowFlag::higherContrastOnPress, WindowFlag::noPush }); +#endif } } // namespace OpenRCT2::Ui::Windows diff --git a/src/openrct2/Diagnostic.cpp b/src/openrct2/Diagnostic.cpp index 713b34b6fd2d..67dd4afa0cbe 100644 --- a/src/openrct2/Diagnostic.cpp +++ b/src/openrct2/Diagnostic.cpp @@ -15,6 +15,7 @@ #include #include +#include #ifdef __ANDROID__ #include diff --git a/src/openrct2/drawing/Font.h b/src/openrct2/drawing/Font.h index fda084eee8b9..5b960a53da57 100644 --- a/src/openrct2/drawing/Font.h +++ b/src/openrct2/drawing/Font.h @@ -14,6 +14,11 @@ #include +// Windows headers define 'small' as a macro for 'char', which conflicts with our enum +#ifdef small + #undef small +#endif + constexpr uint16_t kSpriteFontGlyphCount = 224; enum class FontStyle : uint8_t diff --git a/src/openrct2/libopenrct2.vcxproj b/src/openrct2/libopenrct2.vcxproj index cdc55f28bd65..283ce9846623 100644 --- a/src/openrct2/libopenrct2.vcxproj +++ b/src/openrct2/libopenrct2.vcxproj @@ -619,6 +619,19 @@ + + + + + + + + + + + + + @@ -1152,6 +1165,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/openrct2/terminal/AIAgentLaunch.cpp b/src/openrct2/terminal/AIAgentLaunch.cpp index f8c3dde4cee3..07609281f576 100644 --- a/src/openrct2/terminal/AIAgentLaunch.cpp +++ b/src/openrct2/terminal/AIAgentLaunch.cpp @@ -31,6 +31,11 @@ #if defined(__APPLE__) || defined(__linux__) || defined(__FreeBSD__) || defined(__NetBSD__) || defined(__OpenBSD__) #include +#elif defined(_WIN32) + #include + #define access _access + #define X_OK 0 + #define F_OK 0 #endif namespace OpenRCT2::Terminal @@ -88,6 +93,65 @@ namespace OpenRCT2::Terminal return std::nullopt; } +#elif defined(_WIN32) + std::optional FindExecutable(const std::string& name) + { + if (name.empty()) + { + return std::nullopt; + } + + // Common executable extensions on Windows + const std::vector extensions = { "", ".exe", ".cmd", ".bat", ".com" }; + + // Check if it's an absolute path (contains \ or drive letter like C:) + if (name.find('\\') != std::string::npos || (name.length() >= 2 && name[1] == ':')) + { + for (const auto& ext : extensions) + { + std::string candidate = name + ext; + if (std::filesystem::exists(candidate)) + { + return candidate; + } + } + return std::nullopt; + } + + const char* pathEnv = std::getenv("PATH"); + if (pathEnv == nullptr) + { + return std::nullopt; + } + + std::string_view remaining(pathEnv); + size_t start = 0; + while (start <= remaining.size()) + { + // Windows uses ; as PATH separator + const size_t end = remaining.find(';', start); + auto segment = remaining.substr(start, end == std::string_view::npos ? remaining.size() - start : end - start); + if (!segment.empty()) + { + for (const auto& ext : extensions) + { + std::filesystem::path candidate = std::filesystem::path(segment) / (name + ext); + if (std::filesystem::exists(candidate)) + { + return candidate.string(); + } + } + } + if (end == std::string_view::npos) + { + break; + } + start = end + 1; + } + + return std::nullopt; + } +#endif bool IsStubWorkspaceReadme(const std::filesystem::path& readmePath) { @@ -173,11 +237,19 @@ namespace OpenRCT2::Terminal WorkspaceResult EnsureWorkspace() { +#if defined(_WIN32) + const char* home = std::getenv("USERPROFILE"); + if (!home || !*home) + { + return { {}, false, "USERPROFILE environment variable not set" }; + } +#else const char* home = std::getenv("HOME"); if (!home || !*home) { return { {}, false, "HOME environment variable not set" }; } +#endif auto workspace = std::filesystem::path(home) / kAgentWorkspaceDir; std::error_code ec; std::filesystem::create_directories(workspace, ec); @@ -253,6 +325,11 @@ namespace OpenRCT2::Terminal { const auto base = repoRoot.value_or(std::filesystem::current_path()); std::vector candidates = { + // Windows Visual Studio build locations + base / "bin" / "rctctl", + base / "rctctl" / "build" / "Release" / "rctctl", + base / "rctctl" / "build" / "Debug" / "rctctl", + // CMake build locations (Unix/macOS) base / "build" / "rctctl" / "rctctl", base / "build" / "rctctl" / "Release" / "rctctl", base / "build" / "rctctl" / "Debug" / "rctctl", @@ -371,7 +448,11 @@ namespace OpenRCT2::Terminal auto now = std::chrono::system_clock::now(); auto timeT = std::chrono::system_clock::to_time_t(now); std::tm tm{}; +#if defined(_WIN32) + localtime_s(&tm, &timeT); +#else localtime_r(&timeT, &tm); +#endif std::ostringstream filename; filename << "agent-session-" << std::put_time(&tm, "%Y%m%d-%H%M%S"); @@ -448,13 +529,18 @@ namespace OpenRCT2::Terminal { std::ostringstream buffer; bool first = true; +#if defined(_WIN32) + constexpr char kPathSeparator = ';'; +#else + constexpr char kPathSeparator = ':'; +#endif for (size_t i = 0; i < segments.size(); ++i) { if (!segments[i].empty()) { if (!first) { - buffer << ":"; + buffer << kPathSeparator; } first = false; buffer << segments[i]; @@ -467,7 +553,6 @@ namespace OpenRCT2::Terminal } } -#endif } // namespace AIAgentLaunchPlan BuildAIAgentLaunchPlan(int cols, int rows) @@ -476,7 +561,7 @@ namespace OpenRCT2::Terminal plan.options.cols = cols; plan.options.rows = rows; -#if defined(__APPLE__) || defined(__linux__) || defined(__FreeBSD__) || defined(__NetBSD__) || defined(__OpenBSD__) +#if defined(__APPLE__) || defined(__linux__) || defined(__FreeBSD__) || defined(__NetBSD__) || defined(__OpenBSD__) || defined(_WIN32) // Step 1: Detect repo root - REQUIRED for proper agent setup auto repoRoot = DetectRepoRoot(); if (!repoRoot) @@ -532,6 +617,11 @@ namespace OpenRCT2::Terminal if (const char* customCommand = std::getenv("AGENT_TERMINAL_COMMAND")) { +#if defined(_WIN32) + // On Windows, use cmd.exe to run custom commands + // Session logging via script is not available, but in-game SessionLogGenerator handles it + plan.options.command = { "cmd.exe", "/c", customCommand }; +#else if (sessionLogFile) { // Wrap with script to capture terminal output: script -q /bin/sh -lc @@ -541,6 +631,7 @@ namespace OpenRCT2::Terminal { plan.options.command = { "/bin/sh", "-lc", customCommand }; } +#endif plan.description = customCommand; plan.usesAgent = true; plan.available = true; @@ -550,20 +641,30 @@ namespace OpenRCT2::Terminal // Check for Claude Code CLI (searching for "claude" executable is intentional - it's the actual product binary name) if (auto agentBin = FindExecutable("claude")) { - // Settings to pass to Claude Code CLI (disable spinner tips for cleaner UI) - constexpr const char* kClaudeSettings = R"({"spinnerTipsEnabled":false})"; - // Launch Claude directly - session logs are generated in-game via SessionLogGenerator // on terminal close and /clear command (more reliable than external wrapper scripts) +#if defined(_WIN32) + // On Windows, launch Claude in a separate console window for proper terminal support + // The in-game window will show status while Claude runs in its own native console + plan.options.command = { + agentBin.value(), "--dangerously-skip-permissions" + }; + plan.launchExternal = true; +#else + // Settings to pass to Claude Code CLI (disable spinner tips for cleaner UI) + constexpr const char* kClaudeSettings = R"({"spinnerTipsEnabled":false})"; plan.options.command = { agentBin.value(), "--dangerously-skip-permissions", "--settings", kClaudeSettings }; +#endif plan.description = agentBin.value(); plan.usesAgent = true; plan.available = true; return plan; } +#if !defined(_WIN32) + // Bootstrap script fallback (Unix only - bash scripts don't work on Windows) auto resolveBootstrapScript = [&]() -> std::optional { constexpr std::string_view kBootstrap = "agent_bootstrap.sh"; std::vector candidates; @@ -598,6 +699,7 @@ namespace OpenRCT2::Terminal plan.available = true; return plan; } +#endif plan.error = "Claude Code CLI not found. Install it with: npm install -g @anthropic-ai/claude-code"; plan.available = false; diff --git a/src/openrct2/terminal/AIAgentLaunch.h b/src/openrct2/terminal/AIAgentLaunch.h index ed34152ab842..2c467f680cac 100644 --- a/src/openrct2/terminal/AIAgentLaunch.h +++ b/src/openrct2/terminal/AIAgentLaunch.h @@ -22,6 +22,7 @@ namespace OpenRCT2::Terminal std::string error; bool usesAgent = false; bool available = false; + bool launchExternal = false; // Windows: launch in separate console window }; AIAgentLaunchPlan BuildAIAgentLaunchPlan(int cols, int rows); diff --git a/src/openrct2/terminal/SessionFileMonitor.cpp b/src/openrct2/terminal/SessionFileMonitor.cpp index 6f4c4ae7cee8..82bf7858553a 100644 --- a/src/openrct2/terminal/SessionFileMonitor.cpp +++ b/src/openrct2/terminal/SessionFileMonitor.cpp @@ -544,7 +544,11 @@ namespace OpenRCT2::Terminal std::filesystem::path SessionFileMonitor::GetClaudeProjectsDir() { // Claude stores projects in ~/.claude/projects/ +#if defined(_WIN32) + const char* homeDir = std::getenv("USERPROFILE"); +#else const char* homeDir = std::getenv("HOME"); +#endif if (!homeDir) { return {}; @@ -560,21 +564,37 @@ namespace OpenRCT2::Terminal // 3. Replacing all / with - // 4. Replacing all spaces with - // Example: /Users/foo/Library/Application Support/bar -> -Users-foo-Library-Application-Support-bar + // Windows: C:\Users\foo\bar -> -C-Users-foo-bar std::string pathStr = workspacePath.string(); // Ensure we have an absolute path +#if defined(_WIN32) + // Windows: check for drive letter (e.g., C:\) + if (pathStr.size() < 2 || pathStr[1] != ':') + { + std::error_code ec; + pathStr = std::filesystem::absolute(workspacePath, ec).string(); + } + // Remove drive letter colon (C: -> C) and add leading dash + if (pathStr.size() >= 2 && pathStr[1] == ':') + { + pathStr = "-" + pathStr.substr(0, 1) + pathStr.substr(2); + } + // Replace backslashes with - + std::replace(pathStr.begin(), pathStr.end(), '\\', '-'); +#else if (!pathStr.empty() && pathStr[0] != '/') { std::error_code ec; pathStr = std::filesystem::absolute(workspacePath, ec).string(); } - // Replace leading / with - if (!pathStr.empty() && pathStr[0] == '/') { pathStr[0] = '-'; } +#endif // Replace remaining / with - std::replace(pathStr.begin(), pathStr.end(), '/', '-'); diff --git a/src/openrct2/terminal/SessionLogGenerator.cpp b/src/openrct2/terminal/SessionLogGenerator.cpp index 9ea999b33a21..28f25344b933 100644 --- a/src/openrct2/terminal/SessionLogGenerator.cpp +++ b/src/openrct2/terminal/SessionLogGenerator.cpp @@ -20,6 +20,12 @@ #include "../Diagnostic.h" #include "../platform/Platform.h" +// Windows uses _popen/_pclose instead of popen/pclose +#ifdef _WIN32 + #define popen _popen + #define pclose _pclose +#endif + namespace OpenRCT2::Terminal { namespace @@ -91,13 +97,22 @@ namespace OpenRCT2::Terminal return false; } - // Find python3 + // Find python +#if defined(_WIN32) + std::string python = "python"; + if (std::system("where python >nul 2>&1") != 0) + { + LOG_WARNING("SessionLogGenerator: python not found"); + return false; + } +#else std::string python = "python3"; if (std::system("command -v python3 >/dev/null 2>&1") != 0) { LOG_WARNING("SessionLogGenerator: python3 not found"); return false; } +#endif // Run the markdown converter with the input path (file or directory) std::string command = python + " \"" + scriptPath->string() + "\" \"" + @@ -113,7 +128,7 @@ namespace OpenRCT2::Terminal std::array buffer; std::string cmdOutput; - while (fgets(buffer.data(), buffer.size(), pipe) != nullptr) + while (fgets(buffer.data(), static_cast(buffer.size()), pipe) != nullptr) { cmdOutput += buffer.data(); } @@ -177,12 +192,21 @@ namespace OpenRCT2::Terminal return false; } +#if defined(_WIN32) + std::string python = "python"; + if (std::system("where python >nul 2>&1") != 0) + { + LOG_WARNING("SessionLogGenerator: python not found"); + return false; + } +#else std::string python = "python3"; if (std::system("command -v python3 >/dev/null 2>&1") != 0) { LOG_WARNING("SessionLogGenerator: python3 not found"); return false; } +#endif std::string command = python + " \"" + scriptPath->string() + "\" \"" + markdownPath.string() + "\" -o \"" + outputPath.string() + "\" 2>&1"; @@ -197,7 +221,7 @@ namespace OpenRCT2::Terminal std::array buffer; std::string cmdOutput; - while (fgets(buffer.data(), buffer.size(), pipe) != nullptr) + while (fgets(buffer.data(), static_cast(buffer.size()), pipe) != nullptr) { cmdOutput += buffer.data(); } @@ -383,7 +407,11 @@ namespace OpenRCT2::Terminal // Fallback: user's home directory if (logsDir.empty()) { +#if defined(_WIN32) + const char* home = std::getenv("USERPROFILE"); +#else const char* home = std::getenv("HOME"); +#endif if (home) { logsDir = std::filesystem::path(home) / "OpenRCT2-agent-logs"; @@ -405,7 +433,11 @@ namespace OpenRCT2::Terminal const std::filesystem::path& workspacePath) { // Claude stores projects in ~/.claude/projects/-{workspace-path-with-dashes} +#if defined(_WIN32) + const char* home = std::getenv("USERPROFILE"); +#else const char* home = std::getenv("HOME"); +#endif if (!home) { return std::nullopt; @@ -418,19 +450,27 @@ namespace OpenRCT2::Terminal } // Convert workspace path to project dir name - // e.g., /Users/foo/.openrct2-agent - // becomes -Users-foo-.openrct2-agent + // e.g., /Users/foo/.openrct2-agent -> -Users-foo--openrct2-agent + // e.g., C:\Users\foo\.openrct2-agent -> -C-Users-foo--openrct2-agent std::string workspaceStr = workspacePath.string(); - if (workspaceStr.front() == '/') +#if defined(_WIN32) + // Remove drive letter colon (C: -> C) + if (workspaceStr.length() >= 2 && workspaceStr[1] == ':') + { + workspaceStr = workspaceStr[0] + workspaceStr.substr(2); + } +#else + if (!workspaceStr.empty() && workspaceStr.front() == '/') { workspaceStr = workspaceStr.substr(1); } +#endif std::string projectDirName = "-"; for (char c : workspaceStr) { - if (c == '/' || c == ' ' || c == '.') + if (c == '/' || c == '\\' || c == ' ' || c == '.') { - // Claude converts slashes, spaces, and dots to dashes in project dir names + // Claude converts slashes, backslashes, spaces, and dots to dashes in project dir names projectDirName += '-'; } else diff --git a/src/openrct2/terminal/ShellProcess.cpp b/src/openrct2/terminal/ShellProcess.cpp index 5e18bf3f00c8..d9ed83f6da32 100644 --- a/src/openrct2/terminal/ShellProcess.cpp +++ b/src/openrct2/terminal/ShellProcess.cpp @@ -21,7 +21,15 @@ #include #include -#if defined(__APPLE__) +#if defined(_WIN32) + #ifndef WIN32_LEAN_AND_MEAN + #define WIN32_LEAN_AND_MEAN + #endif + #ifndef NOMINMAX + #define NOMINMAX + #endif + #include +#elif defined(__APPLE__) #include #include #include @@ -300,6 +308,354 @@ namespace OpenRCT2::Terminal return std::make_unique(masterFd, pid, JoinCommand(options.command)); } #endif + +#if defined(_WIN32) + std::string JoinCommandWindows(const std::vector& command) + { + std::ostringstream ss; + for (size_t i = 0; i < command.size(); i++) + { + if (i != 0) + { + ss << ' '; + } + // Quote arguments that contain spaces + const auto& arg = command[i]; + if (arg.find(' ') != std::string::npos || arg.find('\t') != std::string::npos) + { + ss << '"' << arg << '"'; + } + else + { + ss << arg; + } + } + return ss.str(); + } + + class WindowsShellProcess final : public ShellProcess + { + public: + WindowsShellProcess( + HPCON hPC, HANDLE hProcess, HANDLE hThread, HANDLE hPipeIn, HANDLE hPipeOut, std::string description) + : _hPC(hPC) + , _hProcess(hProcess) + , _hThread(hThread) + , _hPipeIn(hPipeIn) + , _hPipeOut(hPipeOut) + , _description(std::move(description)) + { + } + + ~WindowsShellProcess() override + { + Cleanup(); + } + + [[nodiscard]] bool IsRunning() const override + { + if (_exited) + return false; + + DWORD exitCode = 0; + if (GetExitCodeProcess(_hProcess, &exitCode)) + { + if (exitCode != STILL_ACTIVE) + { + _exited = true; + _exitStatus = static_cast(exitCode); + return false; + } + } + return true; + } + + ssize_t Read(uint8_t* buffer, size_t length) override + { + if (_hPipeIn == INVALID_HANDLE_VALUE) + return -1; + + // Check if there's data available (non-blocking) + DWORD available = 0; + if (!PeekNamedPipe(_hPipeIn, nullptr, 0, nullptr, &available, nullptr)) + { + CheckProcess(); + return _exited ? -1 : 0; + } + + if (available == 0) + { + CheckProcess(); + return 0; + } + + DWORD toRead = static_cast(std::min(length, available)); + DWORD bytesRead = 0; + if (!ReadFile(_hPipeIn, buffer, toRead, &bytesRead, nullptr)) + { + CheckProcess(); + return _exited ? -1 : 0; + } + + return static_cast(bytesRead); + } + + bool Write(std::span data) override + { + if (_hPipeOut == INVALID_HANDLE_VALUE || data.empty()) + return false; + + const uint8_t* ptr = data.data(); + size_t remaining = data.size(); + while (remaining > 0) + { + DWORD written = 0; + DWORD toWrite = static_cast(std::min(remaining, 0xFFFFFFFF)); + if (!WriteFile(_hPipeOut, ptr, toWrite, &written, nullptr)) + { + return false; + } + ptr += written; + remaining -= written; + } + return true; + } + + void Resize(int cols, int rows) override + { + if (_hPC == nullptr) + return; + + COORD size; + size.X = static_cast(std::clamp(cols, 2, 500)); + size.Y = static_cast(std::clamp(rows, 2, 500)); + ResizePseudoConsole(_hPC, size); + } + + [[nodiscard]] int ExitStatus() const override + { + return _exitStatus; + } + + [[nodiscard]] std::string_view CommandDescription() const override + { + return _description; + } + + private: + HPCON _hPC = nullptr; + HANDLE _hProcess = INVALID_HANDLE_VALUE; + HANDLE _hThread = INVALID_HANDLE_VALUE; + HANDLE _hPipeIn = INVALID_HANDLE_VALUE; + HANDLE _hPipeOut = INVALID_HANDLE_VALUE; + std::string _description; + mutable bool _exited = false; + mutable int _exitStatus = 0; + + void CheckProcess() + { + if (_exited) + return; + + DWORD exitCode = 0; + if (GetExitCodeProcess(_hProcess, &exitCode) && exitCode != STILL_ACTIVE) + { + _exited = true; + _exitStatus = static_cast(exitCode); + } + } + + void Cleanup() + { + if (_hProcess != INVALID_HANDLE_VALUE && !_exited) + { + // Try graceful termination first + TerminateProcess(_hProcess, 1); + WaitForSingleObject(_hProcess, 500); + } + + if (_hPC != nullptr) + { + ClosePseudoConsole(_hPC); + _hPC = nullptr; + } + if (_hPipeIn != INVALID_HANDLE_VALUE) + { + CloseHandle(_hPipeIn); + _hPipeIn = INVALID_HANDLE_VALUE; + } + if (_hPipeOut != INVALID_HANDLE_VALUE) + { + CloseHandle(_hPipeOut); + _hPipeOut = INVALID_HANDLE_VALUE; + } + if (_hThread != INVALID_HANDLE_VALUE) + { + CloseHandle(_hThread); + _hThread = INVALID_HANDLE_VALUE; + } + if (_hProcess != INVALID_HANDLE_VALUE) + { + CloseHandle(_hProcess); + _hProcess = INVALID_HANDLE_VALUE; + } + } + }; + + std::unique_ptr LaunchWindowsProcess(const ShellLaunchOptions& options, std::string& errorOut) + { + if (options.command.empty()) + { + errorOut = "No command specified for terminal session."; + return nullptr; + } + + HANDLE hPipeInRead = INVALID_HANDLE_VALUE; + HANDLE hPipeInWrite = INVALID_HANDLE_VALUE; + HANDLE hPipeOutRead = INVALID_HANDLE_VALUE; + HANDLE hPipeOutWrite = INVALID_HANDLE_VALUE; + HPCON hPC = nullptr; + + auto cleanup = [&]() { + if (hPipeInRead != INVALID_HANDLE_VALUE) + CloseHandle(hPipeInRead); + if (hPipeInWrite != INVALID_HANDLE_VALUE) + CloseHandle(hPipeInWrite); + if (hPipeOutRead != INVALID_HANDLE_VALUE) + CloseHandle(hPipeOutRead); + if (hPipeOutWrite != INVALID_HANDLE_VALUE) + CloseHandle(hPipeOutWrite); + if (hPC != nullptr) + ClosePseudoConsole(hPC); + }; + + // Create pipes for stdin and stdout + SECURITY_ATTRIBUTES sa = { sizeof(SECURITY_ATTRIBUTES), nullptr, TRUE }; + if (!CreatePipe(&hPipeInRead, &hPipeInWrite, &sa, 0)) + { + errorOut = "Failed to create input pipe"; + cleanup(); + return nullptr; + } + if (!CreatePipe(&hPipeOutRead, &hPipeOutWrite, &sa, 0)) + { + errorOut = "Failed to create output pipe"; + cleanup(); + return nullptr; + } + + // Create the pseudo console + COORD consoleSize; + consoleSize.X = static_cast(std::clamp(options.cols, 2, 500)); + consoleSize.Y = static_cast(std::clamp(options.rows, 2, 500)); + + HRESULT hr = CreatePseudoConsole(consoleSize, hPipeOutRead, hPipeInWrite, 0, &hPC); + if (FAILED(hr)) + { + errorOut = "Failed to create pseudo console (requires Windows 10 1809+)"; + cleanup(); + return nullptr; + } + + // Close the handles that are now owned by the pseudo console + CloseHandle(hPipeOutRead); + hPipeOutRead = INVALID_HANDLE_VALUE; + CloseHandle(hPipeInWrite); + hPipeInWrite = INVALID_HANDLE_VALUE; + + // Initialize the startup info with the pseudo console + STARTUPINFOEXW startupInfo = {}; + startupInfo.StartupInfo.cb = sizeof(STARTUPINFOEXW); + + SIZE_T attrListSize = 0; + InitializeProcThreadAttributeList(nullptr, 1, 0, &attrListSize); + + std::vector attrListBuffer(attrListSize); + startupInfo.lpAttributeList = reinterpret_cast(attrListBuffer.data()); + + if (!InitializeProcThreadAttributeList(startupInfo.lpAttributeList, 1, 0, &attrListSize)) + { + errorOut = "Failed to initialize attribute list"; + cleanup(); + return nullptr; + } + + if (!UpdateProcThreadAttribute( + startupInfo.lpAttributeList, 0, PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, hPC, sizeof(HPCON), nullptr, + nullptr)) + { + errorOut = "Failed to set pseudo console attribute"; + DeleteProcThreadAttributeList(startupInfo.lpAttributeList); + cleanup(); + return nullptr; + } + + // Build the command line + std::string cmdLine = JoinCommandWindows(options.command); + std::wstring wCmdLine(cmdLine.begin(), cmdLine.end()); + + // Build environment block - inherit current environment and add custom vars + std::wstring envBlock; + if (!options.environment.empty()) + { + // Get current environment and copy it + wchar_t* currentEnv = GetEnvironmentStringsW(); + if (currentEnv != nullptr) + { + // Environment block is double-null terminated, each var is null-terminated + const wchar_t* ptr = currentEnv; + while (*ptr != L'\0') + { + size_t len = wcslen(ptr); + envBlock.append(ptr, len); + envBlock += L'\0'; + ptr += len + 1; + } + FreeEnvironmentStringsW(currentEnv); + } + + // Add custom environment variables + for (const auto& env : options.environment) + { + std::wstring wEnv(env.begin(), env.end()); + envBlock += wEnv; + envBlock += L'\0'; + } + envBlock += L'\0'; + } + + // Convert working directory + std::wstring wWorkingDir; + if (!options.workingDirectory.empty()) + { + wWorkingDir = std::wstring(options.workingDirectory.begin(), options.workingDirectory.end()); + } + + PROCESS_INFORMATION procInfo = {}; + BOOL success = CreateProcessW( + nullptr, wCmdLine.data(), + nullptr, // Process security attributes + nullptr, // Thread security attributes + FALSE, // Inherit handles + EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT, + options.environment.empty() ? nullptr : envBlock.data(), + wWorkingDir.empty() ? nullptr : wWorkingDir.c_str(), &startupInfo.StartupInfo, &procInfo); + + DeleteProcThreadAttributeList(startupInfo.lpAttributeList); + + if (!success) + { + DWORD error = GetLastError(); + errorOut = "Failed to create process, error code: " + std::to_string(error); + cleanup(); + return nullptr; + } + + return std::make_unique( + hPC, procInfo.hProcess, procInfo.hThread, hPipeInRead, hPipeOutWrite, + JoinCommandWindows(options.command)); + } +#endif } // namespace bool ShellProcess::Write(std::string_view text) @@ -311,8 +667,10 @@ namespace OpenRCT2::Terminal { #if defined(__APPLE__) || defined(__linux__) || defined(__FreeBSD__) || defined(__NetBSD__) || defined(__OpenBSD__) return LaunchPosixProcess(options, errorOut); +#elif defined(_WIN32) + return LaunchWindowsProcess(options, errorOut); #else - errorOut = "Agent terminal is only supported on POSIX builds right now."; + errorOut = "Agent terminal is not supported on this platform."; return nullptr; #endif } diff --git a/src/openrct2/terminal/ShellProcess.h b/src/openrct2/terminal/ShellProcess.h index 90c0ea494601..faadb27819e8 100644 --- a/src/openrct2/terminal/ShellProcess.h +++ b/src/openrct2/terminal/ShellProcess.h @@ -10,12 +10,18 @@ #pragma once #include +#include #include #include #include #include #include +#if defined(_WIN32) && !defined(ssize_t) + #include + using ssize_t = SSIZE_T; +#endif + namespace OpenRCT2::Terminal { struct ShellLaunchOptions