diff --git a/.github/workflows/multi-platform.yml b/.github/workflows/multi-platform.yml index 72d300a..95cb21f 100644 --- a/.github/workflows/multi-platform.yml +++ b/.github/workflows/multi-platform.yml @@ -3,8 +3,11 @@ name: Build Geode Mod on: workflow_dispatch: push: - branches: - - "**" + branches: [main, master] + pull_request: + +permissions: + contents: read jobs: build: @@ -14,27 +17,29 @@ jobs: config: - name: Windows os: windows-latest - + target: Win64 - name: macOS os: macos-latest - + target: MacOS - name: iOS os: macos-latest target: iOS - - name: Android32 os: ubuntu-latest target: Android32 - - name: Android64 os: ubuntu-latest target: Android64 name: ${{ matrix.config.name }} runs-on: ${{ matrix.config.os }} + timeout-minutes: 30 + continue-on-error: ${{ matrix.config.target == 'iOS' }} steps: - uses: actions/checkout@v4 + with: + submodules: recursive - name: Build the mod uses: geode-sdk/build-geode-mod@main @@ -47,8 +52,13 @@ jobs: name: Package builds runs-on: ubuntu-latest needs: ['build'] + if: always() steps: + - uses: actions/download-artifact@v4 + with: + path: artifacts + - uses: geode-sdk/build-geode-mod/combine@main id: build @@ -56,3 +66,4 @@ jobs: with: name: Build Output path: ${{ steps.build.outputs.build-output }} + if-no-files-found: warn diff --git a/CMakeLists.txt b/CMakeLists.txt index 0674ef6..1a0eb50 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -8,7 +8,7 @@ else() endif() set(CMAKE_CXX_VISIBILITY_PRESET hidden) -project(EditorAI VERSION 2.1.9) +project(EditorAI VERSION 2.1.10) file(GLOB_RECURSE SOURCES CONFIGURE_DEPENDS src/*.cpp) add_library(${PROJECT_NAME} SHARED ${SOURCES}) @@ -28,6 +28,46 @@ endif() add_subdirectory($ENV{GEODE_SDK} ${CMAKE_CURRENT_BINARY_DIR}/geode) +# Workaround for SDK 5.10.1 iOS bindings bug: CCKeyboardDispatcher modify has empty address +# Codegen generates `GEODE_APPLY_MODIFY_FOR_CONSTRUCTOR(, Default, ...)` which expands to +# `static auto address = ;` -> error: expected expression on iOS. +# Fix Modify.hpp to handle empty AddressInline_ as 0, plus patch generated file. +if (IOS OR "${CMAKE_SYSTEM_NAME}" STREQUAL "iOS") + # Configure-time: patch Modify.hpp directly (present before codegen) + set(_modify_hpp "$ENV{GEODE_SDK}/loader/include/Geode/modify/Modify.hpp") + if (EXISTS "${_modify_hpp}") + file(READ "${_modify_hpp}" _mod_content) + if (_mod_content MATCHES "static auto address = AddressInline_;") + string(REPLACE "static auto address = AddressInline_;" "static auto address = (AddressInline_ + 0);" _mod_content "${_mod_content}") + file(WRITE "${_modify_hpp}" "${_mod_content}") + message(STATUS "Patched Modify.hpp for iOS empty address bug (configure-time)") + endif() + endif() + # Also try patching already-generated CCKeyboardDispatcher.hpp if present + set(_ios_patch_file "${CMAKE_CURRENT_BINARY_DIR}/bindings/bindings/Geode/modify/CCKeyboardDispatcher.hpp") + if (EXISTS "${_ios_patch_file}") + file(READ "${_ios_patch_file}" _ios_content) + string(REPLACE "GEODE_APPLY_MODIFY_FOR_CONSTRUCTOR(, Default, cocos2d::CCKeyboardDispatcher, )" "// PATCHED IOS: constructor disabled" _ios_content "${_ios_content}") + string(REPLACE "GEODE_APPLY_MODIFY_FOR_DESTRUCTOR(, Default, cocos2d::CCKeyboardDispatcher)" "// PATCHED IOS: destructor disabled" _ios_content "${_ios_content}") + file(WRITE "${_ios_patch_file}" "${_ios_content}") + message(STATUS "Patched CCKeyboardDispatcher.hpp for iOS (configure-time)") + endif() + # Build-time patch via custom target (runs every build, after Codegen) + add_custom_target(patch-ios-bindings ALL + COMMAND ${CMAKE_COMMAND} -P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/patch-ios.cmake" + COMMENT "Patching iOS bindings CCKeyboardDispatcher if needed" + VERBATIM + ) + add_dependencies(${PROJECT_NAME} patch-ios-bindings) + # Ensure Codegen runs before patch (patch must be after Codegen generates the file) + if (TARGET GeodeCodegen) + add_dependencies(patch-ios-bindings GeodeCodegen) + endif() + if (TARGET codegen) + add_dependencies(patch-ios-bindings codegen) + endif() +endif() + # ImGui overlay — the mod's primary UI (E key on desktop, floating bubble # on mobile). Platforms without it fall back to the classic cocos popups # (src/overlay.cpp compiles to nothing; main.cpp re-adds the AI button). diff --git a/cmake/patch-ios.cmake b/cmake/patch-ios.cmake new file mode 100644 index 0000000..97cd758 --- /dev/null +++ b/cmake/patch-ios.cmake @@ -0,0 +1,33 @@ +# Patch for iOS SDK 5.10.1 bug: CCKeyboardDispatcher modify has empty AddressInline_ +# Fix 1: Patch Modify.hpp to handle empty AddressInline_ (static auto address = ; -> compile error) +# Change `static auto address = AddressInline_;` to `static auto address = (AddressInline_ + 0);` +# which evaluates to 0 when empty and to the address otherwise. + +# Try to patch the SDK's Modify.hpp if it exists (configure-time and build-time) +foreach(_sdk_path IN ITEMS "$ENV{GEODE_SDK}" "${CMAKE_CURRENT_SOURCE_DIR}/geode-sdk-clone" "/Users/runner/work/EditorAI-optimized/EditorAI-optimized/geode-sdk-clone") + set(_mod_file "${_sdk_path}/loader/include/Geode/modify/Modify.hpp") + if(EXISTS "${_mod_file}") + file(READ "${_mod_file}" _content) + if(_content MATCHES "static auto address = AddressInline_;") + string(REPLACE "static auto address = AddressInline_;" "static auto address = (AddressInline_ + 0);" _content "${_content}") + file(WRITE "${_mod_file}" "${_content}") + message(STATUS "Patched Modify.hpp at ${_mod_file} for iOS empty address bug") + endif() + endif() +endforeach() + +# Fix 2: Also patch the generated CCKeyboardDispatcher.hpp if it already exists (post-codegen) +set(_patch_file "${CMAKE_CURRENT_BINARY_DIR}/bindings/bindings/Geode/modify/CCKeyboardDispatcher.hpp") +if(NOT EXISTS "${_patch_file}") + set(_patch_file "${CMAKE_BINARY_DIR}/bindings/bindings/Geode/modify/CCKeyboardDispatcher.hpp") +endif() +if(EXISTS "${_patch_file}") + file(READ "${_patch_file}" _c) + string(FIND "${_c}" "GEODE_APPLY_MODIFY_FOR_CONSTRUCTOR(, Default, cocos2d::CCKeyboardDispatcher" _found) + if(NOT _found EQUAL -1) + string(REPLACE "GEODE_APPLY_MODIFY_FOR_CONSTRUCTOR(, Default, cocos2d::CCKeyboardDispatcher, )" "// PATCHED IOS: constructor disabled" _c "${_c}") + string(REPLACE "GEODE_APPLY_MODIFY_FOR_DESTRUCTOR(, Default, cocos2d::CCKeyboardDispatcher)" "// PATCHED IOS: destructor disabled" _c "${_c}") + file(WRITE "${_patch_file}" "${_c}") + message(STATUS "Patched ${_patch_file} for iOS") + endif() +endif() diff --git a/mod.json b/mod.json index 8773173..dd22bb9 100644 --- a/mod.json +++ b/mod.json @@ -199,28 +199,34 @@ "type": "title", "name": "Providers" }, - "gemini-model": { - "type": "string", - "default": "gemini-3-flash", - "name": "Gemini Model", - "description": "Use Fetch model list in the in-mod Provider tab to pull the live list, or type any Gemini model id." - }, + "gemini-model": { + "type": "string", + "default": "gemini-3.8-flash", + "name": "Gemini Model", + "description": "Use Fetch model list in the in-mod Provider tab to pull the live list, or type any Gemini model id." + }, "gemini-api-key": { "type": "string", "default": "", "name": "Gemini API Key", "description": "From Google AI Studio. Stays on this device in Geode save data." }, + "disable-thinking": { + "type": "bool", + "default": true, + "name": "Disable Thinking", + "description": "For Gemini Flash models, skip the thinking/reasoning phase for much faster (but shallower) answers. Pro models cannot disable thinking and ignore this. Gemini only; takes effect on the next generation." + }, "_provider-claude": { "type": "title", "name": "Provider — Claude" }, - "claude-model": { - "type": "string", - "default": "claude-sonnet-4-6", - "name": "Claude Model", - "description": "Use Fetch model list in the in-mod Provider tab to pull the live list, or type any Claude model id." - }, + "claude-model": { + "type": "string", + "default": "claude-sonnet-5-5", + "name": "Claude Model", + "description": "Use Fetch model list in the in-mod Provider tab to pull the live list, or type any Claude model id." + }, "claude-api-key": { "type": "string", "default": "", @@ -231,12 +237,12 @@ "type": "title", "name": "Provider — OpenAI" }, - "openai-model": { - "type": "string", - "default": "gpt-4o", - "name": "OpenAI Model", - "description": "Use Fetch model list in the in-mod Provider tab to pull the live list, or type any OpenAI model id." - }, + "openai-model": { + "type": "string", + "default": "gpt-6-astra", + "name": "OpenAI Model", + "description": "Use Fetch model list in the in-mod Provider tab to pull the live list, or type any OpenAI model id." + }, "openai-api-key": { "type": "string", "default": "", @@ -263,12 +269,12 @@ "type": "title", "name": "Provider — Hugging Face" }, - "huggingface-model": { - "type": "string", - "default": "meta-llama/Llama-3.1-8B-Instruct", - "name": "HF Model", - "description": "Repo-style id, e.g. meta-llama/Llama-3.1-8B-Instruct." - }, + "huggingface-model": { + "type": "string", + "default": "meta-llama/Llama-3.1-8B-Instruct", + "name": "HF Model", + "description": "Repo-style id, e.g. meta-llama/Llama-3.1-8B-Instruct." + }, "huggingface-api-key": { "type": "string", "default": "", @@ -279,12 +285,12 @@ "type": "title", "name": "Provider — OpenRouter" }, - "openrouter-model": { - "type": "string", - "default": "google/gemini-2.5-flash", - "name": "OpenRouter Model", - "description": "Provider/model slug, e.g. google/gemini-2.5-flash or anthropic/claude-3.5-sonnet." - }, + "openrouter-model": { + "type": "string", + "default": "google/gemini-3.8-flash", + "name": "OpenRouter Model", + "description": "Provider/model slug, e.g. google/gemini-3.8-flash or anthropic/claude-3.5-sonnet." + }, "openrouter-api-key": { "type": "string", "default": "", @@ -313,7 +319,7 @@ }, "groq-model": { "type": "string", - "default": "llama-3.3-70b-versatile", + "default": "openai/gpt-oss-120b", "name": "Groq Model", "description": "Pick a preset or type any Groq model id in the in-mod Provider tab. Use Fetch model list to pull the live list." }, diff --git a/src/main.cpp b/src/main.cpp index 7510e74..13fdef5 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -595,7 +595,10 @@ class LocalCallback { // True once a request has been received (or the listener errored/stopped). bool done() const { return m_done.load(); } - std::string query() const { return m_query; } + std::string query() const { + std::lock_guard lk(m_mu); + return m_query; + } void stop() { m_stopRequested = true; @@ -629,10 +632,9 @@ class LocalCallback { socklen_compat clen = sizeof clientAddr; int client = (int)::accept(m_sock, (sockaddr*)&clientAddr, &clen); if (client < 0) { m_done = true; return; } - - // Read the request line. HTTP GET; we only need "GET /cb? HTTP/1.1" - char buf[4096]; - if (!waitReadable(client)) { + // Only accept loopback peers (this socket is bound to 127.0.0.1, but + // belt & suspenders against FD-passing weirdness). + if (clientAddr.sin_addr.s_addr != htonl(INADDR_LOOPBACK)) { #ifdef _WIN32 ::closesocket(client); #else @@ -641,21 +643,42 @@ class LocalCallback { m_done = true; return; } + + // Read until end of HTTP headers (\r\n\r\n), 16KB cap. Old code did + // one 4KB recv — a browser with cookies easily exceeds that and the + // query string got truncated. + std::string req; + req.reserve(8192); + char buf[4096]; + bool gotHeaders = false; + for (int tries = 0; tries < 8; ++tries) { + if (!waitReadable(client)) break; #ifdef _WIN32 - int n = ::recv(client, buf, sizeof buf - 1, 0); + int n = ::recv(client, buf, sizeof buf, 0); #else - ssize_t n = ::recv(client, buf, sizeof buf - 1, 0); + ssize_t n = ::recv(client, buf, sizeof buf, 0); #endif - if (n > 0) { - buf[n] = 0; - std::string req(buf); - // Find "GET HTTP/" + if (n <= 0) break; + req.append(buf, (size_t)n); + if (req.size() > 16384) break; + if (req.find("\r\n\r\n") != std::string::npos) { gotHeaders = true; break; } + } + if (gotHeaders) { + // Expect "GET /cb? HTTP/..." — ignore anything else + // (favicon, stray probes) instead of treating it as OAuth data. size_t sp1 = req.find(' '); size_t sp2 = sp1 == std::string::npos ? std::string::npos : req.find(' ', sp1 + 1); - if (sp1 != std::string::npos && sp2 != std::string::npos) { + size_t eol = req.find("\r\n"); + if (sp1 != std::string::npos && sp2 != std::string::npos && sp2 < eol) { + std::string method = req.substr(0, sp1); std::string path = req.substr(sp1 + 1, sp2 - sp1 - 1); auto q = path.find('?'); - if (q != std::string::npos) m_query = path.substr(q + 1); + std::string base = (q == std::string::npos) ? path : path.substr(0, q); + if ((method == "GET" || method == "get") && + (base == "/cb" || base == "/callback" || base == "/")) { + std::lock_guard lk(m_mu); + m_query = (q == std::string::npos) ? "" : path.substr(q + 1); + } } } const char* body = @@ -663,14 +686,14 @@ class LocalCallback { "

You can close this tab.

" "

Geometry Dash will pick it up from here.

" ""; - char resp[512]; + char resp[1024]; int rlen = std::snprintf(resp, sizeof resp, "HTTP/1.1 200 OK\r\n" "Content-Type: text/html; charset=utf-8\r\n" "Content-Length: %zu\r\n" "Connection: close\r\n\r\n%s", std::strlen(body), body); - if (rlen > 0) ::send(client, resp, rlen, 0); + if (rlen > 0 && (size_t)rlen < sizeof resp) ::send(client, resp, (size_t)rlen, 0); #ifdef _WIN32 ::closesocket(client); #else @@ -692,6 +715,7 @@ class LocalCallback { } int m_sock = -1; + mutable std::mutex m_mu; std::string m_query; std::atomic m_done{false}; std::atomic m_stopRequested{false}; @@ -1225,7 +1249,7 @@ inline bool supportsVision(const std::string& provider, const std::string& model return model.find(s) != std::string::npos; }; if (provider == "openai") - return has("4o") || has("4.1") || has("vision"); + return has("gpt-6") || has("gpt-5") || has("gpt-4") || has("4o") || has("4.1") || has("vision"); // BYOPAK can front the same multimodal models. The locally tested Codex // OpenAI-compatible bridge exposes GPT-5/6 this way and accepts image_url // parts, so provider identity must not hide the vision tools. @@ -1345,6 +1369,15 @@ inline std::string urlFor(const std::string& provider, const std::string& model) return ""; } +// "flash" substring check, case-insensitive — model ids are typed by hand +// ("gemini-2.5-flash", "Gemini-2.5-Flash", ...) and a case-sensitive match +// would silently leave thinking enabled (slow) on Flash models. +inline bool isGeminiFlashModel(const std::string& model) { + std::string low = model; + for (auto& c : low) c = (char)std::tolower((unsigned char)c); + return low.find("flash") != std::string::npos; +} + // ── OpenAI-compatible: tools + messages array, role=tool for tool results ─ // Provider is needed so we can branch on Ollama, which has two divergences // from the rest of the OpenAI-compat family: (1) it streams by default, so @@ -1967,10 +2000,11 @@ inline matjson::Value buildGeminiRequest(const std::vector& history, auto genConfig = matjson::Value::object(); genConfig["temperature"] = 0.7; genConfig["maxOutputTokens"] = 32768; - // Disable the thinking budget for latency — but ONLY on Flash models. - // Pro models cannot disable thinking and reject thinkingBudget: 0 with - // HTTP 400 INVALID_ARGUMENT. - if (model.find("flash") != std::string::npos) { + // Disable the thinking budget for latency — but ONLY on Flash models and + // only when the user left "Disable Thinking" on. Pro models cannot + // disable thinking and reject thinkingBudget: 0 with HTTP 400. + if (isGeminiFlashModel(model) + && geode::Mod::get()->getSettingValue("disable-thinking")) { auto thinkConfig = matjson::Value::object(); thinkConfig["thinkingBudget"] = 0; genConfig["thinkingConfig"] = thinkConfig; @@ -2917,42 +2951,135 @@ static std::pair parseAPIError(const std::string& erro // ─── Per-provider API key / model helpers ───────────────────────────────────── +static std::string sanitizeStoredKey(std::string raw) { + // Same invisible-char/Bearer stripping as live input (AIGeneratorPopup::sanitizeApiKey). + // Pasted keys often carry trailing newline/space, BOM, zero-width spaces, + // NBSP, quotes or a "Bearer " prefix — the #1 cause of 401 with a "correct" key. + auto stripInvisible = [](std::string& str) { + if (str.size() >= 3 && (unsigned char)str[0]==0xEF && (unsigned char)str[1]==0xBB && (unsigned char)str[2]==0xBF) + str.erase(0,3); + std::string out; out.reserve(str.size()); + for (size_t i=0;i "key\n", cleaned by sanitize after). +static std::string collapseImeDoublePaste(const std::string& v) { + if (v.size() >= 2 && (v.size() % 2) == 0) { + size_t h = v.size() / 2; + if (v.compare(0, h, v, h, h) == 0) return v.substr(0, h); + } + return v; +} static std::string getProviderApiKey(const std::string& provider) { - // Prefer a Sign-In OAuth token when one is saved. Only HuggingFace ships - // a true button-only OAuth flow that works from a desktop mod with a - // loopback listener. OpenRouter requires https://*:443/3000 callbacks - // (per their docs), so its PKCE flow is incompatible — paste-key path - // only. Gemini intentionally stays paste-only too. if (provider == "huggingface") { std::string t = oauth::savedToken(provider); - if (!t.empty()) return t; - } - if (provider == "gemini") return Mod::get()->getSettingValue("gemini-api-key"); - if (provider == "claude") return Mod::get()->getSettingValue("claude-api-key"); - if (provider == "openai") return Mod::get()->getSettingValue("openai-api-key"); - if (provider == "ministral") return Mod::get()->getSettingValue("ministral-api-key"); - if (provider == "huggingface") return Mod::get()->getSettingValue("huggingface-api-key"); - if (provider == "openrouter") return Mod::get()->getSettingValue("openrouter-api-key"); - if (provider == "deepseek") return Mod::get()->getSettingValue("deepseek-api-key"); - if (provider == "groq") return Mod::get()->getSettingValue("groq-api-key"); - if (provider == "custom") return Mod::get()->getSettingValue("custom-provider-api-key"); - return ""; // ollama / local — no key needed + if (!t.empty()) return sanitizeStoredKey(t); + } + std::string raw; + if (provider == "gemini") raw = Mod::get()->getSettingValue("gemini-api-key"); + else if (provider == "claude") raw = Mod::get()->getSettingValue("claude-api-key"); + else if (provider == "openai") raw = Mod::get()->getSettingValue("openai-api-key"); + else if (provider == "ministral") raw = Mod::get()->getSettingValue("ministral-api-key"); + else if (provider == "huggingface") raw = Mod::get()->getSettingValue("huggingface-api-key"); + else if (provider == "openrouter") raw = Mod::get()->getSettingValue("openrouter-api-key"); + else if (provider == "deepseek") raw = Mod::get()->getSettingValue("deepseek-api-key"); + else if (provider == "groq") raw = Mod::get()->getSettingValue("groq-api-key"); + else if (provider == "custom") raw = Mod::get()->getSettingValue("custom-provider-api-key"); + else return ""; + return sanitizeStoredKey(raw); } +static std::string trimModelId(std::string s) { + const std::string ws = " \t\r\n\"'`"; + size_t a = s.find_first_not_of(ws); + if (a==std::string::npos) return ""; + size_t b = s.find_last_not_of(ws); + s = s.substr(a,b-a+1); + // users sometimes paste "models/gemini-..." for openai compat; keep but trim + return s; +} +// One-time migration for retired model ids. mod.json defaults and presets now +// track the latest ids from provider docs (e.g. claude-sonnet-5-5, gpt-6-astra, +// gemini-3.8-flash), but users upgrading from older versions still have the +// previous defaults stored (e.g. claude-sonnet-4-20250514, which is no longer +// in any preset list and renders as a stray "custom..." entry). Migrate ONLY +// those exact outdated defaults to their successors; any other value — +// including other older preset ids the user may have picked deliberately +// (e.g. gpt-4.1, gemini-2.5-pro, kimi-k2, ...) — is a custom choice and is +// left untouched. +static void migrateRetiredModelsOnce() { + static bool done = false; + if (done) return; + done = true; + try { + auto mod = Mod::get(); + auto migrate = [&](const char* key, const std::string& cur, const std::string& latest, + const std::vector& retired) { + for (auto* r : retired) { + if (cur == r) { + mod->setSettingValue(key, latest); + log::info("Migrated retired model '{}': '{}' -> '{}'", key, cur, latest); + break; + } + } + }; + // Each entry below is the single previous mod.json default for that + // key. Anything else stays as-is so custom user picks survive updates. + migrate("claude-model", trimModelId(mod->getSettingValue("claude-model")), + "claude-sonnet-5-5", {"claude-sonnet-4-20250514"}); + migrate("openai-model", trimModelId(mod->getSettingValue("openai-model")), + "gpt-6-astra", {"gpt-4o"}); + migrate("gemini-model", trimModelId(mod->getSettingValue("gemini-model")), + "gemini-3.8-flash", {"gemini-2.5-flash"}); + migrate("openrouter-model", trimModelId(mod->getSettingValue("openrouter-model")), + "google/gemini-3.8-flash", {"google/gemini-2.5-flash"}); + // groq default is unchanged (openai/gpt-oss-120b) — no migration, so + // custom groq picks are never overwritten. + } catch (...) {} +} static std::string getProviderModel(const std::string& provider) { - if (provider == "gemini") return Mod::get()->getSettingValue("gemini-model"); - if (provider == "claude") return Mod::get()->getSettingValue("claude-model"); - if (provider == "openai") return Mod::get()->getSettingValue("openai-model"); - if (provider == "ministral") return Mod::get()->getSettingValue("ministral-model"); - if (provider == "huggingface") return Mod::get()->getSettingValue("huggingface-model"); - if (provider == "ollama") return Mod::get()->getSettingValue("ollama-model"); - if (provider == "lm-studio") return Mod::get()->getSettingValue("lm-studio-model"); - if (provider == "llama-cpp") return Mod::get()->getSettingValue("llama-cpp-model"); - if (provider == "openrouter") return Mod::get()->getSettingValue("openrouter-model"); - if (provider == "deepseek") return Mod::get()->getSettingValue("deepseek-model"); - if (provider == "groq") return Mod::get()->getSettingValue("groq-model"); - if (provider == "custom") return Mod::get()->getSettingValue("custom-provider-model"); - return "unknown"; + migrateRetiredModelsOnce(); + std::string raw; + if (provider == "gemini") raw = Mod::get()->getSettingValue("gemini-model"); + else if (provider == "claude") raw = Mod::get()->getSettingValue("claude-model"); + else if (provider == "openai") raw = Mod::get()->getSettingValue("openai-model"); + else if (provider == "ministral") raw = Mod::get()->getSettingValue("ministral-model"); + else if (provider == "huggingface") raw = Mod::get()->getSettingValue("huggingface-model"); + else if (provider == "ollama") raw = Mod::get()->getSettingValue("ollama-model"); + else if (provider == "lm-studio") raw = Mod::get()->getSettingValue("lm-studio-model"); + else if (provider == "llama-cpp") raw = Mod::get()->getSettingValue("llama-cpp-model"); + else if (provider == "openrouter") raw = Mod::get()->getSettingValue("openrouter-model"); + else if (provider == "deepseek") raw = Mod::get()->getSettingValue("deepseek-model"); + else if (provider == "groq") raw = Mod::get()->getSettingValue("groq-model"); + else if (provider == "custom") raw = Mod::get()->getSettingValue("custom-provider-model"); + else return "unknown"; + std::string t = trimModelId(raw); + return t.empty() ? "unknown" : t; } // ─── Custom provider (BYOPAK — Bring Your Own Provider And Key) ───────────── @@ -3108,11 +3235,20 @@ static std::string resolveCustomChatUrl(std::string url) { size_t first = 0; while (first < url.size() && std::isspace((unsigned char)url[first])) ++first; if (first) url.erase(0, first); + // Pasted "localhost:1234" with no scheme never reaches the server (the + // request fails locally, looking like a freeze) — assume plain http. + if (!url.empty() && url.find("://") == std::string::npos) + url = "http://" + url; if (url.find("/chat/completions") == std::string::npos && url.find("/v1/messages") == std::string::npos && url.find("/completions") == std::string::npos) { if (!url.empty() && url.back() == '/') url.pop_back(); - url += "/v1/chat/completions"; + // A bare ".../v1" base already carries the version prefix — don't + // double it into ".../v1/v1/chat/completions" (404 on every server). + if (url.size() >= 3 && url.compare(url.size() - 3, 3, "/v1") == 0) + url += "/chat/completions"; + else + url += "/v1/chat/completions"; } return url; } @@ -3315,9 +3451,17 @@ inline Analysis analyzeFile(const std::string& path, const std::string& name) { for (unsigned i = 0; i < frames; ++i) { double s = 0; for (int c = 0; c < channels; ++c) { - s += isFloat - ? (double)reinterpret_cast(buf.data())[i * channels + c] - : (double)reinterpret_cast(buf.data())[i * channels + c] / 32768.0; + // memcpy per sample: buf is vector, may be unaligned — + // reinterpret_cast here is UB (aliasing). + double sample = 0; + if (isFloat) { + float f = 0; std::memcpy(&f, buf.data() + (i * channels + c) * 4u, 4u); + sample = (double)f; + } else { + int16_t v = 0; std::memcpy(&v, buf.data() + (i * channels + c) * 2u, 2u); + sample = (double)v / 32768.0; + } + s += sample; } s /= channels; acc += s * s; @@ -3339,13 +3483,19 @@ inline Analysis analyzeFile(const std::string& path, const std::string& name) { // Autocorrelate the flux over the 60–200 BPM lag range, with harmonic // support (half/double tempo) folded into each candidate's score. + // Precompute once — old code re-ran O(N) corr per lag ×3 (harmonic). const int lagMin = std::max(2, (int)(frameRate * 60.f / 200.f)); const int lagMax = std::min((int)env.size() / 2, (int)(frameRate * 60.f / 60.f)); - auto corrAt = [&](int lag) -> double { - if (lag < 2 || lag >= (int)flux.size() - 1) return 0.0; + const int corrMax = std::min((int)flux.size() - 1, lagMax * 2 + 1); + std::vector corr(corrMax + 1, 0.0); + for (int lag = 2; lag <= corrMax; ++lag) { double s = 0; - for (size_t i = 0; i + lag < flux.size(); ++i) s += flux[i] * flux[i + lag]; - return s / (double)(flux.size() - lag); + for (size_t i = 0; i + (size_t)lag < flux.size(); ++i) s += flux[i] * flux[i + lag]; + corr[lag] = s / (double)(flux.size() - lag); + } + auto corrAt = [&](int lag) -> double { + if (lag < 2 || lag > corrMax) return 0.0; + return corr[lag]; }; double bestScore = 0, meanScore = 0; int bestLag = 0, counted = 0; @@ -3399,8 +3549,11 @@ inline Analysis analyzeFile(const std::string& path, const std::string& name) { } // Cached per-path analysis. GD sessions touch a handful of songs at most. +// Mutex + cap 8: analyze runs off the game thread in places, and an +// unbounded static map grows forever across sessions. inline Analysis& analyzeLevelSong(GJGameLevel* level) { static std::unordered_map cache; + static std::mutex cacheMu; static Analysis missing; std::string name; std::string path = songPathFor(level, name); @@ -3413,8 +3566,10 @@ inline Analysis& analyzeLevelSong(GJGameLevel* level) { missing.songName = name; return missing; } + std::lock_guard lk(cacheMu); auto it = cache.find(path); if (it == cache.end()) { + if (cache.size() >= 8) cache.clear(); // simple LRU-ish evict log::info("songsync: analyzing '{}' ({})", name, path); it = cache.emplace(path, analyzeFile(path, name)).first; if (it->second.ok) @@ -3595,22 +3750,22 @@ inline int tryInt(const std::string& s, int dflt) { return res ? res.unwrap() : dflt; } -// True iff `s` parses cleanly as a leading number. Lets us probe a positional -// token: if it's numeric, treat it as x/y; if not, it's a variant/color/kind -// keyword. Mirrors the try/catch dance the SAW handler did inline. +// True iff `s` starts with a parseable number (leading-prefix semantics, +// same as tryFloat: "30," counts as numeric so positional probes agree). inline bool isNumericTok(const std::string& s) { if (s.empty()) return false; size_t i = 0; - if (s[i] == '+' || s[i] == '-') ++i; - bool sawDigit = false, sawDot = false; - for (; i < s.size(); ++i) { + while (i < s.size() && (s[i] == ' ' || s[i] == '\t')) ++i; + if (i < s.size() && (s[i] == '+' || s[i] == '-')) ++i; + size_t d = i; + bool dot = false; + while (i < s.size()) { unsigned char c = (unsigned char)s[i]; - if (std::isdigit(c)) { sawDigit = true; continue; } - if (c == '.' && !sawDot) { sawDot = true; continue; } - if (c == 'e' || c == 'E') { return sawDigit; } - return false; + if (std::isdigit(c)) { ++i; continue; } + if (c == '.' && !dot) { dot = true; ++i; continue; } + break; } - return sawDigit; + return i > d; } // First kv key (in order) that's set on the line — returns its float value, @@ -3801,8 +3956,28 @@ struct ExprParser { inline std::string expandRepeatStatement(const std::string& stmt, int idx) { std::string out; out.reserve(stmt.size() + 8); + auto emitVal = [&out](double v) { + if (std::abs(v - std::round(v)) < 1e-6 && v < 1e9) + out += fmt::format("{:.0f}", v); + else + out += fmt::format("{:.2f}", v); + }; for (size_t p = 0; p < stmt.size();) { if (stmt[p] != '$') { out += stmt[p++]; continue; } + // $(...) form allows spaces: "$(i + 1) * 30". Scan to matching paren. + if (p + 1 < stmt.size() && stmt[p + 1] == '(') { + int depth = 0; + size_t q = p + 1; + for (; q < stmt.size(); ++q) { + if (stmt[q] == '(') ++depth; + else if (stmt[q] == ')') { --depth; if (depth == 0) break; } + } + if (depth != 0) { out += stmt[p++]; continue; } // unbalanced + ExprParser ep(stmt.substr(p + 2, q - p - 2), idx); + emitVal(ep.expr()); + p = q + 1; + continue; + } // Expression = contiguous run of expression characters after the '$'. // Space-free: a `$expr` is a single token, so a following positional // arg ("SAW $i*40 165") is never absorbed. '.' counts as a decimal @@ -3822,12 +3997,7 @@ inline std::string expandRepeatStatement(const std::string& stmt, int idx) { } if (q == p + 1) { out += stmt[p++]; continue; } // lone '$' ExprParser ep(stmt.substr(p + 1, q - p - 1), idx); - double v = ep.expr(); - // Whole number → integer form; otherwise keep two decimals. - if (std::abs(v - std::round(v)) < 1e-6 && v < 1e9) - out += fmt::format("{:.0f}", v); - else - out += fmt::format("{:.2f}", v); + emitVal(ep.expr()); p = q; } return out; @@ -3860,6 +4030,7 @@ inline std::string orbType(const std::string& color) { if (c == "gravity") return "obj_blue_gravity_orb"; if (c == "teleport") return "obj_teleport_orb"; if (c == "toggle") return "obj_toggle_orb"; + geode::log::warn("EAS: unknown orb '{}' — using yellow", color); return "jump_orb_yellow_jump_orb"; } inline std::string padType(const std::string& color) { @@ -3869,6 +4040,7 @@ inline std::string padType(const std::string& color) { if (c == "red") return "jump_pad_red_jump_pad"; if (c == "blue") return "obj_blue_gravity_pad"; if (c == "spider") return "obj_spider_pad"; + geode::log::warn("EAS: unknown pad '{}' — using yellow", color); return "jump_pad_yellow_jump_pad"; } inline std::string portalType(const std::string& kind) { @@ -3897,6 +4069,7 @@ inline std::string portalType(const std::string& kind) { if (c == "teleport-orange") return "portal_unlinked_orange_teleport_portal"; if (c == "teleport-linked") return "portal_linked_teleport_portals"; if (c == "dual") return "portal_dual_portal"; + geode::log::warn("EAS: unknown portal '{}' — using cube", kind); return "portal_cube_portal"; } inline std::string spikeVariant(const std::string& v) { @@ -4015,6 +4188,31 @@ inline void applyCommonFields(matjson::Value& obj, const Line& ln) { if (ln.kv.count("editor_layer_2"))obj["editor_layer_2"]= (double)ln.inum("editor_layer_2", 0); } +// Clamp AI-provided trigger numerics so hallucinations (1e9, -5, NaN) +// can't corrupt the level. Ranges are GD-sane, not physics-exact. +inline float clampAt(float v) { + if (!std::isfinite(v)) return 0.f; + return std::clamp(v, 0.f, 250000.f); +} +inline float clampDur(float v, float dflt = 0.5f) { + if (!std::isfinite(v)) return dflt; + return std::clamp(v, 0.f, 10.f); +} +inline float clamp01(float v, float dflt = 1.f) { + if (!std::isfinite(v)) return dflt; + return std::clamp(v, 0.f, 1.f); +} +// groups="1,2" on a TRIGGER line: GD triggers target ONE group. Keep the +// first (old behavior) but warn so AI typos don't vanish silently. +inline int firstTargetGroup(const Line& ln) { + const char* key = ln.kv.count("target") ? "target" : "groups"; + if (!ln.kv.count(key)) return 1; + std::string s = ln.str(key); + if (s.find(',') != std::string::npos) + geode::log::warn("EAS: multi-group '{}' on TRIGGER — using first only", s); + return ln.inum(key, 1); +} + // ── Trigger emitter (always position-triggered, never touch-triggered) ───── // // The mod's downstream code treats `{"type":"", "x":..., "y":..., @@ -4061,7 +4259,7 @@ inline ParseResult parse(std::string_view text) { // Stride through lines std::string cur; - std::vector pending; // lines queued by REPEAT expansion + std::deque pending; // lines queued by REPEAT expansion (deque: O(1) pop_front) auto handle_inner = [&](const std::string& raw) { auto ln = tokenize(raw); if (ln.verb.empty()) return; @@ -4106,39 +4304,30 @@ inline ParseResult parse(std::string_view text) { // times, substituting $i and tiny numeric expressions per iteration. // Pure line-splitting + substitution — no interpreter, deterministic. if (ln.verb == "repeat") { - // Split raw into whitespace tokens to isolate count + statement. - std::vector toks; - { - std::string curTok; - std::string s = trim(raw); - for (size_t ci = 0; ci <= s.size(); ++ci) { - char c = ci < s.size() ? s[ci] : ' '; - if (c == ' ' || c == '\t') { - if (!curTok.empty()) { toks.push_back(curTok); curTok.clear(); } - } else curTok.push_back(c); - } - } + // Isolate count + statement WITHOUT losing quotes: slice `raw` + // after the count token instead of re-joining whitespace toks + // (old code turned `NAME "a b"` into `NAME "a b"` split apart). + std::string s = trim(raw); + // skip verb + size_t p = 0; + while (p < s.size() && s[p] != ' ' && s[p] != '\t') ++p; + while (p < s.size() && (s[p] == ' ' || s[p] == '\t')) ++p; + // second token = count + size_t c0 = p; + while (p < s.size() && s[p] != ' ' && s[p] != '\t') ++p; + std::string tok1 = s.substr(c0, p - c0); int count = 0; - size_t stmtStart = 1; - if (toks.size() > 1) { - if (isNumericTok(toks[1])) { - count = tryInt(toks[1], 0); - stmtStart = 2; - } else if (toks[1].rfind("count=", 0) == 0) { - count = tryInt(toks[1].substr(6), 0); - stmtStart = 2; - } + bool hasCountTok = false; + if (!tok1.empty()) { + if (isNumericTok(tok1)) { count = tryInt(tok1, 0); hasCountTok = true; } + else if (tok1.rfind("count=", 0) == 0) { count = tryInt(tok1.substr(6), 0); hasCountTok = true; } } if (count <= 0) count = (int)ln.inum("count", 0); if (count <= 0 || count > 200) { geode::log::warn("EAS: REPEAT needs a count 1..200 - line skipped"); return; } - std::string stmt; - for (size_t t = stmtStart; t < toks.size(); ++t) { - if (!stmt.empty()) stmt += " "; - stmt += toks[t]; - } + std::string stmt = hasCountTok ? trim(s.substr(p)) : trim(s.substr(c0)); if (stmt.empty()) { geode::log::warn("EAS: REPEAT has no statement - skipped"); return; } // No nesting — a REPEAT whose statement is itself a REPEAT would // queue unboundedly. Flatten only one level. @@ -4184,7 +4373,7 @@ inline ParseResult parse(std::string_view text) { auto t = triggerObj("effect_color_trigger", ln.fnum("at", 0), 0, ln); if (ln.kv.count("ch")) t["color_channel"] = (double)ln.inum("ch", 1); if (ln.kv.count("hex")) t["color"] = hexToRGBArray(ln.str("hex")); - if (ln.kv.count("duration")) t["duration"] = (double)ln.fnum("duration", 0.5f); + if (ln.kv.count("duration")) t["duration"] = (double)clampDur(ln.fnum("duration", 0.5f)); if (ln.flag("blend")) t["blending"] = true; objects.push(t); return; @@ -4627,41 +4816,41 @@ inline ParseResult parse(std::string_view text) { if (ln.verb == "trigger") { if (ln.pos.empty()) return; std::string kind = lower(ln.pos[0]); - float at = ln.fnum("at", 0); + float at = clampAt(ln.fnum("at", 0)); if (kind == "color") { auto t = triggerObj("effect_color_trigger", at, 0, ln); if (ln.kv.count("ch")) t["color_channel"] = (double)ln.inum("ch", 1); if (ln.kv.count("channel")) t["color_channel"] = (double)ln.inum("channel", 1); if (ln.kv.count("hex")) t["color"] = hexToRGBArray(ln.str("hex")); - if (ln.kv.count("duration")) t["duration"] = (double)ln.fnum("duration", 0.5f); - if (ln.kv.count("opacity")) t["opacity"] = (double)ln.fnum("opacity", 1.f); + if (ln.kv.count("duration")) t["duration"] = (double)clampDur(clampDur(ln.fnum("duration", 0.5f))); + if (ln.kv.count("opacity")) t["opacity"] = (double)clamp01(clamp01(ln.fnum("opacity", 1.f))); if (ln.flag("blend")) t["blending"] = true; if (ln.flag("blending")) t["blending"] = true; objects.push(t); } else if (kind == "alpha") { auto t = triggerObj("effect_alpha_trigger", at, 0, ln); - if (ln.kv.count("groups")) t["target_group"] = (double)ln.inum("groups", 1); - if (ln.kv.count("target")) t["target_group"] = (double)ln.inum("target", 1); - if (ln.kv.count("to")) t["opacity"] = (double)ln.fnum("to", 1.f); - if (ln.kv.count("opacity")) t["opacity"] = (double)ln.fnum("opacity", 1.f); - if (ln.kv.count("duration")) t["duration"] = (double)ln.fnum("duration", 0.5f); + if (ln.kv.count("groups")) t["target_group"] = (double)firstTargetGroup(ln); + if (ln.kv.count("target")) t["target_group"] = (double)firstTargetGroup(ln); + if (ln.kv.count("to")) t["opacity"] = (double)clamp01(ln.fnum("to", 1.f)); + if (ln.kv.count("opacity")) t["opacity"] = (double)clamp01(ln.fnum("opacity", 1.f)); + if (ln.kv.count("duration")) t["duration"] = (double)clampDur(ln.fnum("duration", 0.5f)); objects.push(t); } else if (kind == "move") { auto t = triggerObj("effect_move_trigger", at, 0, ln); - if (ln.kv.count("groups")) t["target_group"] = (double)ln.inum("groups", 1); - if (ln.kv.count("target")) t["target_group"] = (double)ln.inum("target", 1); + if (ln.kv.count("groups")) t["target_group"] = (double)firstTargetGroup(ln); + if (ln.kv.count("target")) t["target_group"] = (double)firstTargetGroup(ln); if (ln.kv.count("dx")) t["move_x"] = (double)ln.fnum("dx", 0); if (ln.kv.count("dy")) t["move_y"] = (double)ln.fnum("dy", 0); if (ln.kv.count("move_x")) t["move_x"] = (double)ln.fnum("move_x", 0); if (ln.kv.count("move_y")) t["move_y"] = (double)ln.fnum("move_y", 0); - if (ln.kv.count("duration")) t["duration"] = (double)ln.fnum("duration", 0.5f); + if (ln.kv.count("duration")) t["duration"] = (double)clampDur(ln.fnum("duration", 0.5f)); if (ln.flag("lock_to_player_x")) t["lock_to_player_x"] = true; if (ln.flag("lock_to_player_y")) t["lock_to_player_y"] = true; objects.push(t); } else if (kind == "toggle") { auto t = triggerObj("effect_toggle_trigger", at, 0, ln); - if (ln.kv.count("groups")) t["target_group"] = (double)ln.inum("groups", 1); - if (ln.kv.count("target")) t["target_group"] = (double)ln.inum("target", 1); + if (ln.kv.count("groups")) t["target_group"] = (double)firstTargetGroup(ln); + if (ln.kv.count("target")) t["target_group"] = (double)firstTargetGroup(ln); t["activate_group"] = ln.flag("on") || ln.flag("activate"); objects.push(t); } else if (kind == "pulse") { @@ -4669,10 +4858,10 @@ inline ParseResult parse(std::string_view text) { // Pulse targets EITHER a color channel OR a group, not both. if (ln.kv.count("ch")) t["target_color_channel"] = (double)ln.inum("ch", 1); if (ln.kv.count("channel")) t["target_color_channel"] = (double)ln.inum("channel", 1); - if (ln.kv.count("groups")) t["target_group"] = (double)ln.inum("groups", 1); - if (ln.kv.count("target")) t["target_group"] = (double)ln.inum("target", 1); + if (ln.kv.count("groups")) t["target_group"] = (double)firstTargetGroup(ln); + if (ln.kv.count("target")) t["target_group"] = (double)firstTargetGroup(ln); if (ln.kv.count("hex")) t["color"] = hexToRGBArray(ln.str("hex")); - if (ln.kv.count("duration")) t["duration"] = (double)ln.fnum("duration", 0.5f); + if (ln.kv.count("duration")) t["duration"] = (double)clampDur(ln.fnum("duration", 0.5f)); if (ln.kv.count("fade_in")) t["fade_in"] = (double)ln.fnum("fade_in", 0.f); if (ln.kv.count("hold")) t["hold"] = (double)ln.fnum("hold", 0.f); if (ln.kv.count("fade_out")) t["fade_out"] = (double)ln.fnum("fade_out", 0.f); @@ -4680,36 +4869,36 @@ inline ParseResult parse(std::string_view text) { objects.push(t); } else if (kind == "rotate") { auto t = triggerObj("effect_rotate_trigger", at, 0, ln); - if (ln.kv.count("groups")) t["target_group"] = (double)ln.inum("groups", 1); - if (ln.kv.count("target")) t["target_group"] = (double)ln.inum("target", 1); + if (ln.kv.count("groups")) t["target_group"] = (double)firstTargetGroup(ln); + if (ln.kv.count("target")) t["target_group"] = (double)firstTargetGroup(ln); if (ln.kv.count("center")) t["center_group"] = (double)ln.inum("center", 1); if (ln.kv.count("degrees")) t["degrees"] = (double)ln.fnum("degrees", 360); - if (ln.kv.count("duration")) t["duration"] = (double)ln.fnum("duration", 1); + if (ln.kv.count("duration")) t["duration"] = (double)clampDur(ln.fnum("duration", 1), 1.f); if (ln.flag("lock_rotation") || ln.flag("lock_object_rotation")) t["lock_object_rotation"] = true; objects.push(t); } else if (kind == "spawn") { auto t = triggerObj("effect_spawn_trigger", at, 0, ln); - if (ln.kv.count("target")) t["target_group"] = (double)ln.inum("target", 1); - if (ln.kv.count("groups")) t["target_group"] = (double)ln.inum("groups", 1); + if (ln.kv.count("target")) t["target_group"] = (double)firstTargetGroup(ln); + if (ln.kv.count("groups")) t["target_group"] = (double)firstTargetGroup(ln); if (ln.kv.count("delay")) t["delay"] = (double)ln.fnum("delay", 0); if (ln.flag("editor_disable")) t["editor_disable"] = true; objects.push(t); } else if (kind == "stop") { auto t = triggerObj("effect_stop_trigger", at, 0, ln); - if (ln.kv.count("groups")) t["target_group"] = (double)ln.inum("groups", 1); - if (ln.kv.count("target")) t["target_group"] = (double)ln.inum("target", 1); + if (ln.kv.count("groups")) t["target_group"] = (double)firstTargetGroup(ln); + if (ln.kv.count("target")) t["target_group"] = (double)firstTargetGroup(ln); objects.push(t); } else if (kind == "scale") { auto t = triggerObj("effect_scale_trigger", at, 0, ln); - if (ln.kv.count("groups")) t["target_group"] = (double)ln.inum("groups", 1); - if (ln.kv.count("target")) t["target_group"] = (double)ln.inum("target", 1); + if (ln.kv.count("groups")) t["target_group"] = (double)firstTargetGroup(ln); + if (ln.kv.count("target")) t["target_group"] = (double)firstTargetGroup(ln); if (ln.kv.count("to")) t["scale"] = (double)ln.fnum("to", 1); - if (ln.kv.count("duration")) t["duration"] = (double)ln.fnum("duration", 0.5f); + if (ln.kv.count("duration")) t["duration"] = (double)clampDur(ln.fnum("duration", 0.5f)); objects.push(t); } else if (kind == "shake") { auto t = triggerObj("effect_shake_trigger", at, 0, ln); - if (ln.kv.count("duration")) t["duration"] = (double)ln.fnum("duration", 1); + if (ln.kv.count("duration")) t["duration"] = (double)clampDur(ln.fnum("duration", 1), 1.f); if (ln.kv.count("strength")) t["strength"] = (double)ln.fnum("strength", 1); if (ln.kv.count("interval")) t["interval"] = (double)ln.fnum("interval", 0); objects.push(t); @@ -4717,14 +4906,14 @@ inline ParseResult parse(std::string_view text) { // TRIGGER zoom at=X zoom=1.5 [duration=T] — 2.2 camera zoom auto t = triggerObj("effect_zoom_camera_trigger", at, 0, ln); if (ln.kv.count("zoom")) t["zoom"] = (double)ln.fnum("zoom", 1); - if (ln.kv.count("duration")) t["duration"] = (double)ln.fnum("duration", 0.5f); + if (ln.kv.count("duration")) t["duration"] = (double)clampDur(ln.fnum("duration", 0.5f)); objects.push(t); } else if (kind == "static-cam" || kind == "static_cam" || kind == "camera-static") { // TRIGGER static-cam at=X target=G [duration=T] [exit] — lock camera to group auto t = triggerObj("effect_static_camera_trigger", at, 0, ln); - if (ln.kv.count("target")) t["target_group"] = (double)ln.inum("target", 1); - if (ln.kv.count("groups")) t["target_group"] = (double)ln.inum("groups", 1); - if (ln.kv.count("duration")) t["duration"] = (double)ln.fnum("duration", 0.5f); + if (ln.kv.count("target")) t["target_group"] = (double)firstTargetGroup(ln); + if (ln.kv.count("groups")) t["target_group"] = (double)firstTargetGroup(ln); + if (ln.kv.count("duration")) t["duration"] = (double)clampDur(ln.fnum("duration", 0.5f)); if (ln.flag("exit")) t["exit"] = true; objects.push(t); } else if (kind == "offset-cam" || kind == "offset_cam" || kind == "camera-offset") { @@ -4732,7 +4921,7 @@ inline ParseResult parse(std::string_view text) { auto t = triggerObj("effect_offset_camera_trigger", at, 0, ln); if (ln.kv.count("x")) t["move_x"] = (double)ln.fnum("x", 0); if (ln.kv.count("y")) t["move_y"] = (double)ln.fnum("y", 0); - if (ln.kv.count("duration")) t["duration"] = (double)ln.fnum("duration", 0.5f); + if (ln.kv.count("duration")) t["duration"] = (double)clampDur(ln.fnum("duration", 0.5f)); objects.push(t); } else if (kind == "timewarp") { // TRIGGER timewarp at=X mod=0.5 — slow-mo / speed-up (2.2) @@ -4756,29 +4945,29 @@ inline ParseResult parse(std::string_view text) { } else if (kind == "follow") { // TRIGGER follow at=X target=G follow=G2 [x_mod=1 y_mod=1 duration=T] auto t = triggerObj("effect_follow_trigger", at, 0, ln); - if (ln.kv.count("groups")) t["target_group"] = (double)ln.inum("groups", 1); - if (ln.kv.count("target")) t["target_group"] = (double)ln.inum("target", 1); + if (ln.kv.count("groups")) t["target_group"] = (double)firstTargetGroup(ln); + if (ln.kv.count("target")) t["target_group"] = (double)firstTargetGroup(ln); if (ln.kv.count("follow")) t["follow_group"] = (double)ln.inum("follow", 1); if (ln.kv.count("x_mod")) t["x_mod"] = (double)ln.fnum("x_mod", 1); if (ln.kv.count("y_mod")) t["y_mod"] = (double)ln.fnum("y_mod", 1); - if (ln.kv.count("duration")) t["duration"] = (double)ln.fnum("duration", 10); + if (ln.kv.count("duration")) t["duration"] = (double)clampDur(ln.fnum("duration", 10), 10.f); objects.push(t); } else if (kind == "follow-y" || kind == "follow_y" || kind == "follow-player-y") { // TRIGGER follow-y at=X target=G [speed=S delay=D offset=O max_speed=M duration=T] auto t = triggerObj("effect_follow_player_y_trigger", at, 0, ln); - if (ln.kv.count("groups")) t["target_group"] = (double)ln.inum("groups", 1); - if (ln.kv.count("target")) t["target_group"] = (double)ln.inum("target", 1); + if (ln.kv.count("groups")) t["target_group"] = (double)firstTargetGroup(ln); + if (ln.kv.count("target")) t["target_group"] = (double)firstTargetGroup(ln); if (ln.kv.count("speed")) t["speed"] = (double)ln.fnum("speed", 1); if (ln.kv.count("delay")) t["delay"] = (double)ln.fnum("delay", 0); if (ln.kv.count("offset")) t["offset"] = (double)ln.inum("offset", 0); if (ln.kv.count("max_speed")) t["max_speed"] = (double)ln.fnum("max_speed", 0); - if (ln.kv.count("duration")) t["duration"] = (double)ln.fnum("duration", 10); + if (ln.kv.count("duration")) t["duration"] = (double)clampDur(ln.fnum("duration", 10), 10.f); objects.push(t); } else if (kind == "touch") { // TRIGGER touch at=X target=G [activate] [hold] — player-tap activation auto t = triggerObj("effect_touch_trigger", at, 0, ln); - if (ln.kv.count("groups")) t["target_group"] = (double)ln.inum("groups", 1); - if (ln.kv.count("target")) t["target_group"] = (double)ln.inum("target", 1); + if (ln.kv.count("groups")) t["target_group"] = (double)firstTargetGroup(ln); + if (ln.kv.count("target")) t["target_group"] = (double)firstTargetGroup(ln); if (ln.flag("activate")) t["activate"] = true; if (ln.flag("hold")) t["hold"] = true; objects.push(t); @@ -4789,8 +4978,8 @@ inline ParseResult parse(std::string_view text) { at, 0, ln); if (ln.kv.count("item_id")) t["item_id"] = (double)ln.inum("item_id", 1); if (ln.kv.count("count")) t["count"] = (double)ln.inum("count", 1); - if (ln.kv.count("groups")) t["target_group"] = (double)ln.inum("groups", 1); - if (ln.kv.count("target")) t["target_group"] = (double)ln.inum("target", 1); + if (ln.kv.count("groups")) t["target_group"] = (double)firstTargetGroup(ln); + if (ln.kv.count("target")) t["target_group"] = (double)firstTargetGroup(ln); if (ln.flag("activate")) t["activate"] = true; objects.push(t); } else if (kind == "pickup") { @@ -4802,8 +4991,8 @@ inline ParseResult parse(std::string_view text) { } else if (kind == "on-death" || kind == "on_death") { // TRIGGER on-death target=G [activate] — fires when the player dies auto t = triggerObj("effect_on_death_trigger", at, 0, ln); - if (ln.kv.count("groups")) t["target_group"] = (double)ln.inum("groups", 1); - if (ln.kv.count("target")) t["target_group"] = (double)ln.inum("target", 1); + if (ln.kv.count("groups")) t["target_group"] = (double)firstTargetGroup(ln); + if (ln.kv.count("target")) t["target_group"] = (double)firstTargetGroup(ln); if (ln.flag("activate")) t["activate"] = true; objects.push(t); } else if (kind == "end") { @@ -4813,8 +5002,8 @@ inline ParseResult parse(std::string_view text) { // TRIGGER animate groups=G anim=N at=X — play animation N on // the animated objects in group G auto t = triggerObj("effect_animate_trigger", at, 0, ln); - if (ln.kv.count("groups")) t["target_group"] = (double)ln.inum("groups", 1); - if (ln.kv.count("target")) t["target_group"] = (double)ln.inum("target", 1); + if (ln.kv.count("groups")) t["target_group"] = (double)firstTargetGroup(ln); + if (ln.kv.count("target")) t["target_group"] = (double)firstTargetGroup(ln); if (ln.kv.count("anim")) t["animation_id"] = (double)ln.inum("anim", 0); if (ln.kv.count("animation_id")) t["animation_id"] = (double)ln.inum("animation_id", 0); @@ -4825,14 +5014,14 @@ inline ParseResult parse(std::string_view text) { auto t = triggerObj("effect_gravity_trigger", at, 0, ln); if (ln.kv.count("g")) t["gravity"] = (double)ln.fnum("g", 1.f); if (ln.kv.count("gravity")) t["gravity"] = (double)ln.fnum("gravity", 1.f); - if (ln.kv.count("duration")) t["duration"] = (double)ln.fnum("duration", 0.5f); + if (ln.kv.count("duration")) t["duration"] = (double)clampDur(ln.fnum("duration", 0.5f)); objects.push(t); } else if (kind == "teleport") { // TRIGGER teleport groups=G at=X — teleport the player to // group G's location (2.2 teleport trigger) auto t = triggerObj("effect_teleport_trigger", at, 0, ln); - if (ln.kv.count("groups")) t["target_group"] = (double)ln.inum("groups", 1); - if (ln.kv.count("target")) t["target_group"] = (double)ln.inum("target", 1); + if (ln.kv.count("groups")) t["target_group"] = (double)firstTargetGroup(ln); + if (ln.kv.count("target")) t["target_group"] = (double)firstTargetGroup(ln); objects.push(t); } else if (kind == "reverse") { objects.push(triggerObj("effect_reverse_trigger", at, 0, ln)); @@ -4863,9 +5052,12 @@ inline ParseResult parse(std::string_view text) { // values instead of needing a catch-all here. auto handle = [&](const std::string& raw) { handle_inner(raw); }; auto drainPending = [&] { - while (!pending.empty()) { + // Guard: expanded lines are never REPEAT (blocked at expansion), so + // this cannot recurse — cap is just belt & suspenders. + size_t guard = 0; + while (!pending.empty() && guard++ < 5000) { std::string p = std::move(pending.front()); - pending.erase(pending.begin()); + pending.pop_front(); handle(p); } }; @@ -5044,11 +5236,15 @@ inline std::string objectsToEAS(const matjson::Value& objectsArray, if (o.contains("groups") && o["groups"].isArray() && o["groups"].size() > 0) { suffix += " groups="; const auto& gs = o["groups"]; + bool first = true; for (size_t g = 0; g < gs.size(); ++g) { - auto gi = gs[g].asInt(); - if (!gi) continue; - if (g) suffix += ","; - suffix += fmt::format("{}", gi.unwrap()); + int gv = 0; bool ok = false; + if (auto gi = gs[g].asInt()) { gv = (int)gi.unwrap(); ok = true; } + else if (auto gd = gs[g].asDouble()) { gv = (int)std::lround(gd.unwrap()); ok = true; } + if (!ok) continue; + if (!first) suffix += ","; + first = false; + suffix += fmt::format("{}", gv); } } auto sc = o["scale"].asDouble(); @@ -5085,20 +5281,33 @@ inline std::string objectsToEAS(const matjson::Value& objectsArray, } else if (o.contains("color") && o["color"].isArray() && o["color"].size() >= 3) { const auto& carr = o["color"]; - auto rr = carr[0].asInt(); auto gg = carr[1].asInt(); auto bb = carr[2].asInt(); + auto chanInt = [](const matjson::Value& v) -> int { + if (auto i = v.asInt()) return std::clamp((int)i.unwrap(), 0, 255); + if (auto d = v.asDouble()) return std::clamp((int)std::lround(d.unwrap()), 0, 255); + return 255; + }; out += fmt::format(" hex={:02x}{:02x}{:02x}", - rr ? std::clamp((int)rr.unwrap(), 0, 255) : 255, - gg ? std::clamp((int)gg.unwrap(), 0, 255) : 255, - bb ? std::clamp((int)bb.unwrap(), 0, 255) : 255); - } - auto trigCh = o["color_channel"].asInt(); - if (trigCh) out += fmt::format(" ch={}", trigCh.unwrap()); - for (auto& [jk, ek] : TRIG_FIELDS) { - if (!o.contains(jk)) continue; - auto num = o[jk].asDouble(); - if (num) { out += fmt::format(" {}={}", ek, fmtNum(num.unwrap())); continue; } - auto b = o[jk].asBool(); - if (b && b.unwrap()) out += fmt::format(" {}", ek); + chanInt(carr[0]), chanInt(carr[1]), chanInt(carr[2])); + } + { + int trigChV = 0; + if (auto ti = o["color_channel"].asInt()) trigChV = (int)ti.unwrap(); + else if (auto td = o["color_channel"].asDouble()) trigChV = (int)std::lround(td.unwrap()); + if (trigChV) out += fmt::format(" ch={}", trigChV); + } + { + // TRIG_FIELDS has {"opacity","to"} + {"scale","to"} — same EAS + // keyword for two JSON fields. Emit at most once per keyword. + std::unordered_set emitted; + for (auto& [jk, ek] : TRIG_FIELDS) { + if (!o.contains(jk)) continue; + if (!emitted.insert(ek).second) continue; + // Prefer double, fall back to int (matjson typed accessors). + if (auto num = o[jk].asDouble()) { out += fmt::format(" {}={}", ek, fmtNum(num.unwrap())); continue; } + if (auto ni = o[jk].asInt()) { out += fmt::format(" {}={}", ek, fmtNum((double)ni.unwrap())); continue; } + auto b = o[jk].asBool(); + if (b && b.unwrap()) out += fmt::format(" {}", ek); + } } auto act = o["activate"].asBool(); if (act && act.unwrap()) out += " activate"; @@ -5111,10 +5320,12 @@ inline std::string objectsToEAS(const matjson::Value& objectsArray, std::string gl; const auto& garr = o["groups"]; for (size_t gi = 0; gi < garr.size(); ++gi) { - auto gv = garr[gi].asInt(); - if (!gv) continue; + int gv = 0; bool ok = false; + if (auto vi = garr[gi].asInt()) { gv = (int)vi.unwrap(); ok = true; } + else if (auto vd = garr[gi].asDouble()) { gv = (int)std::lround(vd.unwrap()); ok = true; } + if (!ok) continue; if (!gl.empty()) gl += ","; - gl += std::to_string(gv.unwrap()); + gl += std::to_string(gv); } if (!gl.empty()) out += fmt::format(" own_groups={}", gl); } @@ -5293,6 +5504,9 @@ inline float getFloat(const matjson::Value& v, const std::string& key, float dfl } inline int getInt(const matjson::Value& v, const std::string& key, int dflt) { if (!v.contains(key)) return dflt; + // EAS emits group/id fields as double — accept both (round doubles). + auto d = v[key].asDouble(); + if (d) return (int)std::lround(d.unwrap()); auto r = v[key].asInt(); return r ? (int)r.unwrap() : dflt; } @@ -5318,22 +5532,23 @@ inline std::set parseGroups(const matjson::Value& v) { std::set out; if (!v.contains("groups")) return out; auto& g = v["groups"]; + auto pushNum = [&out](const matjson::Value& n) { + if (auto i = n.asInt()) { out.insert((int)i.unwrap()); return; } + if (auto d = n.asDouble()) { out.insert((int)std::lround(d.unwrap())); } + }; if (g.isArray()) { - for (size_t i = 0; i < g.size(); ++i) { - auto n = g[i].asInt(); - if (n) out.insert((int)n.unwrap()); - } + for (size_t i = 0; i < g.size(); ++i) pushNum(g[i]); } else { - auto n = g.asInt(); - if (n) out.insert((int)n.unwrap()); + pushNum(g); } return out; } inline int parseSingleGroup(const matjson::Value& v, const char* key) { if (!v.contains(key)) return 0; - auto n = v[key].asInt(); - return n ? (int)n.unwrap() : 0; + if (auto n = v[key].asInt()) return (int)n.unwrap(); + if (auto d = v[key].asDouble()) return (int)std::lround(d.unwrap()); + return 0; } inline Result check(const matjson::Value& objectsArray) { @@ -5460,7 +5675,8 @@ inline Result check(const matjson::Value& objectsArray) { // For each column, a bitmask of blocked Y rows. totalRows is 18 with the // constants above, so a uint32 covers the playfield — far cheaper than // the old per-column std::set (node allocation per blocked cell). - const uint32_t fullMask = totalRows >= 32 ? ~0u : ((1u << totalRows) - 1u); + static_assert(18 < 32, "playfield rows must fit in uint32 mask"); + const uint32_t fullMask = ((1u << totalRows) - 1u); std::vector blockedRows(totalCols, 0u); for (const auto& b : blockers) { float effHalfW = b.half_w + PLAYER_DETECTION_HALF; @@ -5470,9 +5686,7 @@ inline Result check(const matjson::Value& objectsArray) { int rowStart = std::max(0, (int)((b.y - effHalfH - Y_MIN) / ROW_STEP)); int rowEnd = std::min(totalRows - 1, (int)((b.y + effHalfH - Y_MIN) / ROW_STEP)); if (rowEnd < rowStart) continue; - uint32_t rowMask = (rowEnd - rowStart + 1 >= 32) - ? ~0u - : (((1u << (rowEnd - rowStart + 1)) - 1u) << rowStart); + uint32_t rowMask = (((1u << (rowEnd - rowStart + 1)) - 1u) << rowStart); for (int c = colStart; c <= colEnd; ++c) blockedRows[c] |= rowMask; } @@ -5757,7 +5971,9 @@ inline SimResult simulateCube(const matjson::Value& objectsArray, float groundY) res.path.reserve(2048); float lastPadX = -1e9f; // a pad fires once per pass, not per tick - for (int tick = 0; tick < 12000; ++tick) { + // Tick cap scales with level length: 200s fixed cap cut off xxl levels. + const int maxTick = std::max(12000, (int)((maxX + 400.f) / VX * 60.f) + 600); + for (int tick = 0; tick < maxTick; ++tick) { // Mode / speed segment advancement. The multiplier in effect BEFORE // crossing a portal moves this tick — GD applies the new speed from // the next frame, and matching that avoids stepping over a spike @@ -5833,7 +6049,7 @@ inline SimResult simulateCube(const matjson::Value& objectsArray, float groundY) fmt::format("corridor too tight for {} ({}u free)", modeName(mode), (int)bestGap)}); if (res.deaths.size() >= 8) break; - x += 60.f; + x += 30.f; // respawn just past the killer — 60 skipped real zones sinceSample = 0; // settle before sampling again while (sWin < solids.size() && solids[sWin].x + solids[sWin].halfW < x - 200.f) ++sWin; @@ -5944,7 +6160,7 @@ inline SimResult simulateCube(const matjson::Value& objectsArray, float groundY) goto survived; died: if (res.deaths.size() >= 8) break; - x += 60.f; // respawn-skip past the killer + x += 30.f; // respawn just past the killer (60 skipped real zones) // Catch the sweep windows up to the teleported x before surfaceAt. while (sWin < solids.size() && solids[sWin].x + solids[sWin].halfW < x - 200.f) ++sWin; while (hWin < hazards.size() && hazards[hWin].x + hazards[hWin].halfW < x - 200.f) ++hWin; @@ -6011,9 +6227,16 @@ static float computeMaxXFromObjects(const matjson::Value& objectsArray) { static const std::unordered_set triggerTypes = { "color_trigger","move_trigger","pulse_trigger","alpha_trigger", "toggle_trigger","spawn_trigger","stop_trigger","rotate_trigger", + "scale_trigger","shake_trigger", "end_trigger","show_trail_trigger","hide_trail_trigger", "show_player_trigger","hide_player_trigger","collision_trigger", - "on_death_trigger","count_trigger", + "on_death_trigger","count_trigger","instant_count_trigger", + "pickup_trigger","animate_trigger","gravity_trigger", + "teleport_trigger","reverse_trigger", + "zoom_camera_trigger","static_camera_trigger","offset_camera_trigger", + "rotate_camera_trigger","edge_camera_trigger","timewarp_trigger", + "song_trigger","sfx_trigger","follow_trigger","follow_player_y_trigger", + "touch_trigger", // EAS triggers go through `effect_*_trigger` aliases "effect_color_trigger","effect_move_trigger","effect_pulse_trigger", "effect_alpha_trigger","effect_toggle_trigger","effect_spawn_trigger", @@ -6027,7 +6250,11 @@ static float computeMaxXFromObjects(const matjson::Value& objectsArray) { "effect_follow_trigger","effect_follow_player_y_trigger", "effect_touch_trigger","effect_count_trigger", "effect_instant_count_trigger","effect_pickup_trigger", - "effect_on_death_trigger", + "effect_on_death_trigger","effect_animate_trigger", + "effect_gravity_trigger","effect_teleport_trigger", + "effect_reverse_trigger", + "effect_background_effect_on_trigger","effect_background_effect_off_trigger", + "effect_no_enter_effect_trigger", }; float maxX = 0.f; for (size_t i = 0; i < objectsArray.size(); ++i) { @@ -7626,7 +7853,7 @@ class AISettingsPopup : public Popup { struct TabUI { CCNode* on = nullptr; CCNode* off = nullptr; CCLabelBMFont* label = nullptr; }; std::array m_tabUI{}; - struct TextRow { std::string sid; TextInput* in; bool password; }; + struct TextRow { std::string sid; TextInput* in; bool password; int maxLen = 200; bool revealed = false; CCMenuItemSpriteExtra* showBtn = nullptr; CCMenuItemSpriteExtra* hideBtn = nullptr; }; struct IntRow { std::string sid; TextInput* in; int64_t min, max, def; }; // CycleRow stores a pointer to the value-display label so the arrow // handlers can update its text in place — no full tab rebuild on cycle. @@ -7761,9 +7988,25 @@ class AISettingsPopup : public Popup { void onClose(CCObject* o) override { flushInputs(); Popup::onClose(o); } void flushInputs() { - for (auto& r : m_texts) - geode::Mod::get()->setSettingValue(r.sid, - std::string(r.in->getString())); + for (auto& r : m_texts) { + std::string v = std::string(r.in->getString()); + // Collapse a macOS paste+Return double-insert BEFORE sanitizing: + // the duplicated half may itself carry a trailing newline/BOM + // ("key\nkey\n"), which sanitize alone cannot repair. + std::string fixed = collapseImeDoublePaste(v); + if (fixed != v) { + v = fixed; + r.in->setString(v); // visible field matches what is stored + } + // Sanitize API keys on save: pasted keys often carry a trailing + // newline/space, BOM, zero-width chars or a "Bearer " prefix. + // Without this the stored key keeps the junk and every validation + // / generation request 401s even though the key itself is valid. + if (r.sid.find("api-key") != std::string::npos + || r.sid.find("api_key") != std::string::npos) + v = sanitizeStoredKey(v); + geode::Mod::get()->setSettingValue(r.sid, v); + } for (auto& r : m_ints) { std::string s = r.in->getString(); int64_t v = r.def; @@ -7966,19 +8209,137 @@ class AISettingsPopup : public Popup { const char* desc = nullptr) { auto row = makeRow(lbl, desc); float w = 150.f; + // Right edge of the text field. The Show/Hide key toggle (password + // rows) and the mobile Paste button live to its right. + float fieldRight = 354.f; +#ifdef GEODE_IS_MOBILE + // Narrow the field to make room for the paste button — many mobile + // keyboards (especially on iOS) offer no paste action at all inside + // GD text boxes. + w = 104.f; + fieldRight = 296.f; +#endif + if (password) { + // Make room for the Show/Hide toggle next to the field. + w = 118.f; + fieldRight = 296.f; +#ifdef GEODE_IS_MOBILE + w = 70.f; + fieldRight = 210.f; +#endif + } auto in = TextInput::create(w, ph, "bigFont.fnt"); in->setScale(0.6f); - // Right edge pinned at x=354. - in->setPosition({354.f - (w * 0.6f) / 2.f, ROW_H / 2.f}); + in->setPosition({fieldRight - (w * 0.6f) / 2.f, ROW_H / 2.f}); in->setMaxCharCount(maxLen); if (password) in->setPasswordMode(true); std::string cur = geode::Mod::get()->getSettingValue(sid); if (!cur.empty()) in->setString(cur); row->addChild(in); - m_texts.push_back({sid, in, password}); + m_texts.push_back({sid, in, password, maxLen}); + if (password) { + // Show/Hide toggle for masked keys: a masked field can't be + // proofread or repaired by hand, so reveal on demand. Keys stay + // masked whenever the popup (re)builds. + auto menu = CCMenu::create(); + menu->setContentSize({56.f, ROW_H}); + menu->ignoreAnchorPointForPosition(false); + menu->setAnchorPoint({0.5f, 0.5f}); +#ifdef GEODE_IS_MOBILE + menu->setPosition({240.f, ROW_H / 2.f}); +#else + menu->setPosition({326.f, ROW_H / 2.f}); +#endif + auto mkBtn = [this, sid](const char* txt, bool visible) { + auto spr = ButtonSprite::create(txt, "bigFont.fnt", + "GJ_button_04.png", 0.35f); + auto btn = CCMenuItemSpriteExtra::create(spr, this, + menu_selector(AISettingsPopup::onToggleShowKey)); + btn->setUserObject(CCString::create(sid)); + btn->setPosition({28.f, ROW_H / 2.f}); + btn->setVisible(visible); + return btn; + }; + auto& tr = m_texts.back(); + tr.showBtn = mkBtn("Show", true); + tr.hideBtn = mkBtn("Hide", false); + menu->addChild(tr.showBtn); + menu->addChild(tr.hideBtn); + row->addChild(menu); + } +#ifdef GEODE_IS_MOBILE + { + auto menu = CCMenu::create(); + menu->setContentSize({64.f, ROW_H}); + menu->ignoreAnchorPointForPosition(false); + menu->setAnchorPoint({0.5f, 0.5f}); + menu->setPosition({330.f, ROW_H / 2.f}); + auto spr = ButtonSprite::create("Paste", "bigFont.fnt", + "GJ_button_04.png", 0.35f); + auto btn = CCMenuItemSpriteExtra::create(spr, this, + menu_selector(AISettingsPopup::onPasteToField)); + btn->setUserObject(CCString::create(sid)); + btn->setPosition({32.f, ROW_H / 2.f}); + menu->addChild(btn); + row->addChild(menu); + } +#endif pushRow(row); } +#ifdef GEODE_IS_MOBILE + // Mobile paste helper: pulls the system clipboard straight into the + // settings field. The pressed button carries its field's sid in the + // userObject (looked up live, so tab rebuilds can never stale it). + void onPasteToField(CCObject* sender) { + auto* node = static_cast(sender); + auto* str = static_cast(node ? node->getUserObject() : nullptr); + if (!str) return; + std::string sid = str->getCString(); + TextInput* target = nullptr; + int maxLen = 0; + for (auto& r : m_texts) + if (r.sid == sid) { target = r.in; maxLen = r.maxLen; break; } + if (!target) return; + std::string clip = utils::clipboard::read(); + auto a = clip.find_first_not_of(" \t\r\n"); + if (a == std::string::npos) { + Notification::create("Clipboard is empty - copy the key/URL first", + NotificationIcon::Warning)->show(); + return; + } + auto b = clip.find_last_not_of(" \t\r\n"); + clip = clip.substr(a, b - a + 1); + if (clip.size() > 2000) { + Notification::create("Clipboard too large to paste here", + NotificationIcon::Warning)->show(); + return; + } + if ((int)clip.size() > maxLen) target->setMaxCharCount((int)clip.size()); + target->setString(clip); + Notification::create("Pasted from clipboard", NotificationIcon::Success)->show(); + } +#endif + + // Show/Hide toggle for masked API key fields. Flips password mode and + // re-sets the string so the label redraws in the new mode (getString + // always returns the real text; only the display is masked). + void onToggleShowKey(CCObject* sender) { + auto* node = static_cast(sender); + auto* str = static_cast(node ? node->getUserObject() : nullptr); + if (!str) return; + std::string sid = str->getCString(); + for (auto& r : m_texts) { + if (r.sid != sid || !r.in) continue; + r.revealed = !r.revealed; + r.in->setPasswordMode(!r.revealed); + r.in->setString(std::string(r.in->getString())); + if (r.showBtn) r.showBtn->setVisible(!r.revealed); + if (r.hideBtn) r.hideBtn->setVisible(r.revealed); + break; + } + } + void addInt(const char* lbl, const char* sid, int64_t mn, int64_t mx, int64_t df, const char* desc = nullptr) { @@ -8404,15 +8765,19 @@ class AISettingsPopup : public Popup { // Hosted providers: a free-text field (type ANY model id) plus a row // of tappable presets. Type-or-pick — every provider now accepts a // custom model id, not just the presets. - if (p == "gemini") + if (p == "gemini") { addModelChooser("gemini-model", "type any Gemini model id", - {"gemini-3-flash","gemini-3-pro","gemini-2.5-flash","gemini-2.5-pro"}); + {"gemini-3.8-flash","gemini-3.5-flash-lite","gemini-3.1-pro","gemma-4-31b-it"}); + addToggle("Disable thinking", "disable-thinking", + "Skips the thinking phase on Flash models: much faster, shallower.\n\n" + "Pro models can't disable thinking and ignore this."); + } else if (p == "claude") addModelChooser("claude-model", "type any Claude model id", - {"claude-sonnet-4-6","claude-opus-4-6","claude-haiku-4-5"}); + {"claude-sonnet-5-5","claude-opus-5-5","claude-haiku-4-5"}); else if (p == "openai") addModelChooser("openai-model", "type any OpenAI model id", - {"gpt-4o","gpt-4.1-mini","gpt-4.1","o4-mini"}); + {"gpt-6-astra","gpt-6.1-sol","gpt-6-luna"}); else if (p == "ministral") addModelChooser("ministral-model", "type any Mistral model id", {"ministral-3b-latest","ministral-8b-latest","mistral-small-latest", @@ -8422,13 +8787,13 @@ class AISettingsPopup : public Popup { {"deepseek-chat","deepseek-reasoner","deepseek-coder"}); else if (p == "groq") addModelChooser("groq-model", "type any Groq model id", - {"llama-3.3-70b-versatile","llama-3.1-8b-instant", - "openai/gpt-oss-120b","openai/gpt-oss-20b", - "moonshotai/kimi-k2-instruct"}); + {"openai/gpt-oss-120b","openai/gpt-oss-20b", + "qwen/qwen3.8-27b"}); else if (p == "openrouter") addModelChooser("openrouter-model", "vendor/model-name", - {"google/gemini-2.5-flash","anthropic/claude-sonnet-4", - "openai/gpt-4o","meta-llama/llama-3.3-70b-instruct"}); + {"google/gemini-3.8-flash","google/gemini-3.5-flash-lite", + "google/gemma-4-31b-it","anthropic/claude-sonnet-5-5", + "openai/gpt-6-astra","meta-llama/llama-3.3-70b-instruct"}); else if (p == "huggingface") addModelChooser("huggingface-model", "owner/repo", {"meta-llama/Llama-3.1-8B-Instruct","Qwen/Qwen2.5-7B-Instruct"}); @@ -8644,8 +9009,10 @@ class AISettingsPopup : public Popup { void onSaveAndTest(CCObject*) { flushInputs(); std::string p = geode::Mod::get()->getSettingValue("ai-provider"); - std::string key = geode::Mod::get()->getSettingValue( - p == "custom" ? "custom-provider-api-key" : (p + "-api-key")); + // Use the sanitized key (flushInputs already stored the cleaned value). + // The old code read the raw setting here, so a pasted key with a + // trailing newline/space always 401d even when the key was valid. + std::string key = getProviderApiKey(p); if (key.empty()) { setAuthStatus("No key to test.", ui::ERROR_COL); return; } @@ -8654,6 +9021,7 @@ class AISettingsPopup : public Popup { runValidate(p, key); } void onTestKey(CCObject*) { + flushInputs(); std::string p = geode::Mod::get()->getSettingValue("ai-provider"); std::string key = getProviderApiKey(p); setAuthStatus("Testing...", ui::BUSY_COL); @@ -8688,8 +9056,19 @@ class AISettingsPopup : public Popup { m_authNet.spawn(req.get(url), [this](web::WebResponse resp) { if (resp.ok()) setAuthStatus("✓ Connected.", ui::SUCCESS_COL); - else setAuthStatus(fmt::format("✗ HTTP {}.", resp.code()), + else if (resp.code() == 401) + setAuthStatus("✗ HTTP 401: key rejected. Re-paste key, Save & test.", ui::ERROR_COL); + else { + // Show the provider's error snippet so a wrong model / + // quota / permission failure isn't mistaken for a bad key. + std::string body = resp.string().unwrapOr(""); + for (auto& c : body) if (c == '\n' || c == '\r') c = ' '; + if (body.size() > 120) body = body.substr(0, 120); + std::string msg = fmt::format("✗ HTTP {}.", resp.code()); + if (!body.empty()) msg += " " + body; + setAuthStatus(msg, ui::ERROR_COL); + } }); } @@ -8720,6 +9099,11 @@ class AISettingsPopup : public Popup { if (provider == "custom") { std::string base = geode::Mod::get()->getSettingValue("custom-provider-url"); if (base.empty()) return ""; + while (!base.empty() && std::isspace((unsigned char)base.back())) base.pop_back(); + size_t f = 0; + while (f < base.size() && std::isspace((unsigned char)base[f])) ++f; + if (f) base.erase(0, f); + if (base.find("://") == std::string::npos) base = "http://" + base; auto pos = base.find("/chat/completions"); if (pos != std::string::npos) base = base.substr(0, pos); while (!base.empty() && base.back() == '/') base.pop_back(); @@ -9849,9 +10233,9 @@ class HistoryPopup : public Popup { // Struct lives in sessions.hpp (shared with overlay.cpp); the registry and // engine seam functions are defined here. -static bool s_sessionsDirty = false; +static std::atomic s_sessionsDirty{false}; static int s_sessionsNextId = 1; -void editoraiMarkSessionsDirty() { s_sessionsDirty = true; } +void editoraiMarkSessionsDirty() { s_sessionsDirty.store(true, std::memory_order_relaxed); } // Load persisted sessions once, on first registry access. Restored sessions // are read-only history: the engine (in-flight network state, tool history) @@ -9954,7 +10338,7 @@ static void loadPersistedSessions(std::vector>& out) if (s->id >= s_sessionsNextId) s_sessionsNextId = s->id + 1; out.push_back(std::move(s)); } - s_sessionsDirty = false; // loading isn't a change + s_sessionsDirty.store(false, std::memory_order_relaxed); // loading isn't a change log::info("EditorAI: restored {} session(s) from disk", out.size()); } @@ -9971,8 +10355,7 @@ std::vector>& genSessions() { // pattern). Throttled by the dirty flag — the overlay ticks this every // few seconds and $on_mod(DataSaved) flushes on exit. void editoraiPersistSessionsIfDirty() { - if (!s_sessionsDirty) return; - s_sessionsDirty = false; + if (!s_sessionsDirty.exchange(false, std::memory_order_acq_rel)) return; auto arr = matjson::Value::array(); for (auto& s : genSessions()) { if (!s) continue; @@ -10043,7 +10426,7 @@ void editoraiPersistSessionsIfDirty() { } $on_mod(DataSaved) { - s_sessionsDirty = true; // force a flush even if throttle just ran + s_sessionsDirty.store(true, std::memory_order_relaxed); // force a flush even if throttle just ran editoraiPersistSessionsIfDirty(); } @@ -13497,16 +13880,42 @@ class AIGeneratorPopup : public Popup { // ── API call ────────────────────────────────────────────────────────────── - // Strips leading/trailing ASCII whitespace (spaces, tabs, newlines, carriage - // returns). API keys are frequently copy-pasted with invisible trailing - // characters that cause 401/403 responses even when the key is valid. - static std::string trimKey(std::string s) { - const std::string ws = " \t\r\n"; + // Strips leading/trailing whitespace + invisible chars that users paste + // accidentally (BOM, zero-width spaces, quotes, "Bearer " prefix). This is + // the #1 cause of 401 despite "correct" key. + static std::string sanitizeApiKey(std::string s) { + // 1) Trim ASCII whitespace + common invisible unicode (BOM, ZWSP, NBSP) + // BOM = EF BB BF, ZWSP = E2 80 8B, NBSP = C2 A0 in UTF-8 + auto stripInvisible = [](std::string& str) { + // remove UTF-8 BOM at start + if (str.size() >= 3 && (unsigned char)str[0]==0xEF && (unsigned char)str[1]==0xBB && (unsigned char)str[2]==0xBF) + str.erase(0,3); + std::string out; out.reserve(str.size()); + for (size_t i=0;i skip + out.push_back(str[i]); ++i; + } + str.swap(out); + }; + stripInvisible(s); + const std::string ws = " \t\r\n\"'`"; size_t start = s.find_first_not_of(ws); if (start == std::string::npos) return ""; size_t end = s.find_last_not_of(ws); - return s.substr(start, end - start + 1); + s = s.substr(start, end - start + 1); + stripInvisible(s); + // 2) If user pasted "Bearer sk-..." or "sk-..." with prefix, strip it + if (s.rfind("Bearer ", 0)==0) s = s.substr(7); + else if (s.rfind("bearer ", 0)==0) s = s.substr(7); + // 3) re-trim after prefix removal + start = s.find_first_not_of(ws); + if (start == std::string::npos) return ""; + end = s.find_last_not_of(ws); + s = s.substr(start, end - start + 1); + return s; } + static std::string trimKey(std::string s) { return sanitizeApiKey(std::move(s)); } // ── Tool 1: download a reference level from GD's servers ─────────────── // Builds a compact summary the AI can use as design inspiration. Skips @@ -14499,9 +14908,22 @@ class AIGeneratorPopup : public Popup { // Entry point. Called instead of callAPI's single-shot when tool use is // enabled and the selected provider supports it. void runToolLoop(const std::string& userPrompt, const std::string& rawApiKey) { - m_toolApiKey = trimKey(rawApiKey); + m_toolApiKey = sanitizeApiKey(rawApiKey); + if (m_toolApiKey.empty()) m_toolApiKey = getProviderApiKey(Mod::get()->getSettingValue("ai-provider")); m_toolProvider = Mod::get()->getSettingValue("ai-provider"); m_toolModel = getProviderModel(m_toolProvider); + if (m_toolModel.empty() || m_toolModel=="unknown") { + onError("Invalid Model", fmtUserError("No model configured for provider '"+m_toolProvider+"'.", + "Open Settings → Provider tab and pick a model.", makeErrorCode(m_toolProvider,40,0))); + return; + } + if (m_toolApiKey.empty() && m_toolProvider!="ollama" && m_toolProvider!="lm-studio" && m_toolProvider!="llama-cpp" && m_toolProvider!="manual" && m_toolProvider!="custom") { + onError("API Key Required", fmtUserError("No API key saved for provider '"+m_toolProvider+"' (tool loop would get HTTP 401).", + "Open Settings → paste a fresh key, then save.", makeErrorCode(m_toolProvider,20,1))); + return; + } + // Diagnostics only: never log key material or fragments (first/last chars). + log::debug("Tool loop preflight: provider={} model='{}' hasKey={}", m_toolProvider, m_toolModel, !m_toolApiKey.empty()); // Tool use is unbounded — no round budget. The model runs until it // emits a final answer; duplicate-call reuse prevents accidental // repeated network work, while Cancel remains available throughout. @@ -17767,9 +18189,10 @@ class AIGeneratorPopup : public Popup { genConfig["temperature"] = 0.7; genConfig["maxOutputTokens"] = 65536; // Disable the thinking budget for latency — but ONLY on Flash - // models. Pro models cannot disable thinking and reject - // thinkingBudget: 0 with HTTP 400 INVALID_ARGUMENT. - if (model.find("flash") != std::string::npos) { + // models with "Disable Thinking" on. Pro models cannot disable + // thinking and reject thinkingBudget: 0 with HTTP 400. + if (toolUse::isGeminiFlashModel(model) + && Mod::get()->getSettingValue("disable-thinking")) { auto thinkingConfig = matjson::Value::object(); thinkingConfig["thinkingBudget"] = 0; genConfig["thinkingConfig"] = thinkingConfig; @@ -17891,6 +18314,24 @@ class AIGeneratorPopup : public Popup { } } + // ── Pre-flight validation (prevents opaque 400/401) ─────────────── + if (model.empty() || model=="unknown") { + onError("Invalid Model", fmtUserError("No model configured for provider '"+provider+"'.", + "Open Settings → Provider tab and pick a model (use Fetch model list).", makeErrorCode(provider,40,0))); + return; + } + if (provider!="ollama" && provider!="lm-studio" && provider!="llama-cpp" && provider!="manual" && provider!="custom" && apiKey.empty()) { + onError("API Key Required", fmtUserError("No API key saved for provider '"+provider+"' (HTTP 401 would follow).", + "Open Settings → paste a fresh key from the provider dashboard, then save.", makeErrorCode(provider,20,1))); + return; + } + // Diagnostics only: never log key material, fragments, or length. + { + log::debug("API preflight: provider={} model='{}' hasKey={} url='{}'", provider, model, !apiKey.empty(), url); + if (apiKey.size()<8 && provider!="ollama" && provider!="lm-studio" && provider!="llama-cpp" && provider!="manual" && provider!="custom") + log::warn("API key looks suspiciously short — pasted correctly?"); + } + std::string jsonBody = requestBody.dump(); log::info("Sending request to {} ({} bytes)", provider, jsonBody.length()); // Context construction is complete. Keeping the durable phase at @@ -20811,12 +21252,16 @@ void editoraiSetBool(const char* id, bool v) { req.header("Content-Type", "application/json"); req.bodyString(body.dump()); } - auto finish = [](web::WebResponse resp) { - s_testStatus = resp.ok() - ? "✓ Connected." - : fmt::format("✗ HTTP {}. Check URL, auth template, and server API compatibility.", resp.code()); - s_testInFlight = false; - }; + auto finish = [](web::WebResponse resp) { + if (resp.ok()) { + s_testStatus = "✓ Connected."; + } else if (resp.code() == 401) { + s_testStatus = "✗ HTTP 401: key rejected. Re-paste the key for THIS provider, Save/Test again (typing alone doesn't save)."; + } else { + s_testStatus = fmt::format("✗ HTTP {}. Check URL, auth template, and server API compatibility.", resp.code()); + } + s_testInFlight = false; + }; if (postCustom) s_testTask.spawn(req.post(url), finish); else s_testTask.spawn(req.get(url), finish); } diff --git a/src/overlay.cpp b/src/overlay.cpp index 201e178..806ba4c 100644 --- a/src/overlay.cpp +++ b/src/overlay.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -273,12 +274,28 @@ void settingInt(const char* label, const char* id, int mn, int mx, // editing flags for fields NOT rendered this frame — settingText is used in // BOTH tabs (composer's custom difficulty/style), and a blanket clear while // the Chat tab renders one would clobber in-progress typing every frame. -struct TextBuf { std::array buf{}; bool editing = false; int lastFrame = -1; }; +struct TextBuf { std::array buf{}; bool editing = false; int lastFrame = -1; }; std::unordered_map& textBufs() { static std::unordered_map b; return b; } +// Flush staged ImGui text buffers into Geode settings. settingText() only +// writes back on IsItemDeactivatedAfterEdit, so a user who types an API key +// and immediately hits "Test connection" would otherwise test the OLD key +// (stale buffer → spurious HTTP 401). Call before any network probe that +// reads the key / URL / model from settings. +// Only flush fields rendered this frame: a hidden custom-model buffer must +// not clobber a freshly picked preset (preset pick hides the text field, +// leaving a stale buf behind). +void flushTextBufs() { + int curFrame = ImGui::GetFrameCount(); + for (auto& [id, tb] : textBufs()) { + if (tb.lastFrame != curFrame) continue; + editoraiSetStr(id.c_str(), tb.buf.data()); + } +} + // Named BYOPAK profiles. Geode saved values live in this mod's local save // directory; nothing here is synced or sent anywhere. A profile includes the // key because endpoints commonly use different credentials, and loading a @@ -400,14 +417,57 @@ void settingText(const char* label, const std::string& id, std::string cur = editoraiGetStr(id.c_str()); snprintf(tb.buf.data(), tb.buf.size(), "%s", cur.c_str()); } + // Per-field key reveal (masked fields can't be proofread by hand). + // Defaults to hidden every restart; the buffer itself always holds the + // real text, only the display is masked. + static std::unordered_map revealed; + bool show = secret && revealed[id]; ImGui::SetNextItemWidth(std::min(280.f, ImGui::GetContentRegionAvail().x)); ImGui::InputTextWithHint(fmt::format("{}##{}", label, id).c_str(), hint, tb.buf.data(), tb.buf.size(), TEXT_SELECTION_FLAGS | - (secret ? ImGuiInputTextFlags_Password : ImGuiInputTextFlags_None), + (secret && !show ? ImGuiInputTextFlags_Password : ImGuiInputTextFlags_None), textSelectionCallback); tb.editing = ImGui::IsItemActive(); tipIfHovered(tip); - if (ImGui::IsItemDeactivatedAfterEdit()) { + // Capture BEFORE the Show/Hide button below: afterwards "last item" is + // the button, and this query would read the wrong widget. + bool finished = ImGui::IsItemDeactivatedAfterEdit(); + if (secret) { + ImGui::SameLine(); + if (ImGui::SmallButton(fmt::format("{}##show-{}", + show ? "Hide" : "Show", id).c_str())) { + revealed[id] = !show; + // Force the buffer back through the widget so it redraws in the + // new mode even while it keeps focus. + tb.editing = false; + } + tipIfHovered("Reveal the key to proofread or repair it by hand."); + } + // Mobile keyboards (especially on iOS) often offer no paste action at + // all, so every settings field gets its own Paste button that pulls the + // system clipboard straight into the field. + if (uiMobile()) { + ImGui::SameLine(); + if (ImGui::SmallButton(fmt::format("Paste##paste-{}", id).c_str())) { + if (const char* clip = ImGui::GetClipboardText()) { + std::string s = clip; + auto a = s.find_first_not_of(" \t\r\n"); + if (a != std::string::npos) { + auto b = s.find_last_not_of(" \t\r\n"); + s = s.substr(a, b - a + 1); + snprintf(tb.buf.data(), tb.buf.size(), "%s", s.c_str()); + tb.editing = false; + editoraiSetStr(id.c_str(), tb.buf.data()); + if (autoBypass && tb.buf[0]) { + editoraiSetBool("bypass-char-filter", true); + editoraiSetBool("bypass-char-limit", true); + } + } + } + } + tipIfHovered("Paste from the system clipboard."); + } + if (finished) { editoraiSetStr(id.c_str(), tb.buf.data()); if (autoBypass && tb.buf[0]) { editoraiSetBool("bypass-char-filter", true); @@ -968,7 +1028,17 @@ void settingModelCombo(const char* id, if (ImGui::BeginCombo(fmt::format("model##{}", id).c_str(), preview)) { for (auto* opt : presets) { bool sel = !custom && cur == opt; - if (ImGui::Selectable(opt, sel)) { editoraiSetStr(id, opt); custom = false; } + if (ImGui::Selectable(opt, sel)) { + editoraiSetStr(id, opt); + custom = false; + // Sync the text buffer immediately: otherwise the hidden + // custom-model buffer still holds the old free-text value and + // a later flushTextBufs() (Test connection) would undo this pick. + auto& tb = textBufs()[id]; + snprintf(tb.buf.data(), tb.buf.size(), "%s", opt); + tb.editing = false; + tb.lastFrame = ImGui::GetFrameCount(); + } if (sel) ImGui::SetItemDefaultFocus(); } ImGui::Separator(); @@ -1046,13 +1116,13 @@ void providerModelWidget(const std::string& p) { "any model id."; if (p == "gemini") settingModelCombo("gemini-model", - {"gemini-3-flash", "gemini-3-pro", "gemini-2.5-flash", "gemini-2.5-pro"}, tip); + {"gemini-3.8-flash", "gemini-3.5-flash-lite", "gemini-3.1-pro", "gemma-4-31b-it"}, tip); else if (p == "claude") settingModelCombo("claude-model", - {"claude-sonnet-4-6", "claude-opus-4-6", "claude-haiku-4-5"}, tip); + {"claude-sonnet-5-5", "claude-opus-5-5", "claude-haiku-4-5"}, tip); else if (p == "openai") settingModelCombo("openai-model", - {"gpt-4o", "gpt-4.1-mini", "gpt-4.1", "o4-mini"}, tip); + {"gpt-6-astra", "gpt-6.1-sol", "gpt-6-luna"}, tip); else if (p == "ministral") settingModelCombo("ministral-model", {"ministral-3b-latest", "ministral-8b-latest", "mistral-small-latest", @@ -1062,16 +1132,15 @@ void providerModelWidget(const std::string& p) { {"deepseek-chat", "deepseek-reasoner", "deepseek-coder"}, tip); else if (p == "groq") settingModelCombo("groq-model", - {"llama-3.3-70b-versatile", "llama-3.1-8b-instant", - "openai/gpt-oss-120b", "openai/gpt-oss-20b", - "moonshotai/kimi-k2-instruct"}, tip); + {"openai/gpt-oss-120b", "openai/gpt-oss-20b", + "qwen/qwen3.8-27b"}, tip); else if (p == "huggingface") settingModelCombo("huggingface-model", {"meta-llama/Llama-3.1-8B-Instruct", "Qwen/Qwen2.5-7B-Instruct"}, tip); else if (p == "openrouter") settingModelCombo("openrouter-model", - {"google/gemini-2.5-flash", "anthropic/claude-sonnet-4", - "openai/gpt-4o", "meta-llama/llama-3.3-70b-instruct"}, tip); + {"google/gemini-3.8-flash", "google/gemini-3.5-flash-lite", "google/gemma-4-31b-it", "anthropic/claude-sonnet-5-5", + "openai/gpt-6-astra", "meta-llama/llama-3.3-70b-instruct"}, tip); else if (p == "ollama") ollamaModelSelector(); else if (p == "lm-studio") @@ -1776,7 +1845,7 @@ void tabSettings() { // Test connection — a real authenticated probe of the current // provider (the same endpoints the AI generation calls). ImGui::SameLine(); - if (ImGui::SmallButton("Test connection")) editoraiTestProvider(); + if (ImGui::SmallButton("Test connection")) { flushTextBufs(); editoraiTestProvider(); } tipIfHovered("Sends a tiny authenticated request to this provider " "to confirm the key/URL work before you generate."); std::string tstat = editoraiTestStatus(); @@ -1819,6 +1888,10 @@ void tabSettings() { "Stored locally on this device and only sent to the " "provider itself.", true); } + if (p == "gemini") + settingToggle("disable thinking (faster, shallower)", "disable-thinking", + "Skips the thinking phase on Flash models. Pro models can't " + "disable thinking and ignore this."); // One-click sign-in where the provider supports it — no key-copying. if (editoraiOAuthAvailable(p)) { ImGui::BeginDisabled(editoraiOAuthActive()); @@ -2020,6 +2093,243 @@ void tabSettings() { } // ── Main draw ───────────────────────────────────────────────────────────────── +#ifdef GEODE_IS_MOBILE +// ── Native cocos floating bubble (mobile) ──────────────────────────────────── +// Eclipse-style: a real CCMenu targeted touch delegate, NOT an ImGui window. +// ImGui's touch emulation only delivers hover on the first tap, which forced +// a double-press before any drag could start — raw touches have no such +// problem: press-and-move drags from the very first touch. +// +// Behavior: round ~62 px bubble; drag freely (position saved across restarts); +// a quick tap toggles the panel; auto-dims to 50% after 5 s untouched; +// hidden completely inside any level (PlayLayer exists). +// +// The circle art is baked into a runtime texture once so a plain CCSprite +// shows it — sprites honor setOpacity reliably. +CCTexture2D* eaiMakeBubbleTexture() { + constexpr int S = 112; + constexpr float R = 54.f; + constexpr float RING = 6.f; + auto* data = new unsigned char[(size_t)S * S * 4]; + for (int y = 0; y < S; ++y) { + for (int x = 0; x < S; ++x) { + float dx = (float)x + 0.5f - S / 2.f; + float dy = (float)y + 0.5f - S / 2.f; + float d = std::sqrt(dx * dx + dy * dy); + float a = std::clamp(R - d, 0.f, 1.f); + unsigned char r, g, b; + // Match inner panel: dark navy fill (COL_BG ~ #171A1F) + accent ring + // (#5CB0FF = COL_ACCENT). Was: white ring + light-blue fill, which + // clashed with the dark UI. + if (d > R - RING) { r = 92; g = 176; b = 255; } // accent ring + else { r = 22; g = 25; b = 31; } // dark fill + size_t i = ((size_t)y * S + x) * 4; + data[i] = r; data[i + 1] = g; data[i + 2] = b; + data[i + 3] = (unsigned char)(a * 255.f); + } + } + auto* tex = new CCTexture2D(); + tex->initWithData(data, kCCTexture2DPixelFormat_RGBA8888, S, S, CCSize(S, S)); + delete[] data; + tex->autorelease(); + return tex; +} + +class EAIBubble : public CCMenu { +protected: + constexpr static float DRAG_SLOP = 8.f; // finger travel before a press counts as a drag (tap vs drag) + constexpr static float TOUCH_RADIUS = 40.f; // round hit area (bubble visual radius is ~31 px) + constexpr static float DIM_OPACITY = 0.5f; // idle dim target + constexpr static float IDLE_DELAY = 5.f; // seconds untouched before dimming + + CCSprite* m_sprite = nullptr; + CCLabelBMFont* m_label = nullptr; + CCPoint m_grabOff{}; // finger-minus-sprite offset, so the bubble never jumps + CCPoint m_pressPos{}; // finger position at press time (for the slop check) + CCPoint m_homePos{}; // sprite position at press time (restored if it was just a tap) + float m_opacity = 1.f; // current opacity, eased toward m_opacityTarget + float m_opacityTarget = 1.f; + float m_idleT = 0.f; // seconds since last touch + bool m_haveMoved = false; + +public: + // Central rule: the bubble is COMPLETELY hidden while a level is active + // (PlayLayer exists) and reappears only after the player exits the level. + // Called every frame from both get() (ImGui tick) and update() (cocos + // tick) so a missed scheduler tick or a scene-change flash can never + // leave it visible mid-attempt. + void refreshForScene() { + bool inLevel = PlayLayer::get() != nullptr; + if (inLevel) { + if (isVisible()) setVisible(false); + // Reset idle so it comes back fully opaque after the level ends. + m_idleT = 0.f; + m_opacityTarget = 1.f; + m_opacity = 1.f; + applyOpacity(); + } else { + if (!isVisible()) { + setVisible(true); + poke(); + } + } + } + + static EAIBubble* get() { + static EAIBubble* s_inst = nullptr; + if (!s_inst) { + s_inst = new EAIBubble(); + if (s_inst->init()) { + // Geode >= 5.10 removed SceneManager/keepAcrossScenes. + // Retain the bubble for the app lifetime and re-parent it + // to the current scene whenever the scene changes. + s_inst->retain(); + } else { + delete s_inst; + s_inst = nullptr; + } + } + if (s_inst) { + if (auto* cur = CCScene::get()) { + if (s_inst->getParent() != cur) { + if (s_inst->getParent()) + s_inst->removeFromParentAndCleanup(false); + cur->addChild(s_inst); + } + } + // Enforce hidden-in-level immediately (no 1-frame flash while + // waiting for the cocos update() tick). + s_inst->refreshForScene(); + } + return s_inst; + } + +protected: + bool init() override { + if (!CCMenu::init()) return false; + setZOrder(100); + setPosition(CCPoint(0, 0)); + setID("eai-bubble"_spr); + // Explicit: this node lives or dies by raw touches, make sure no + // GD/CCMenu default ever leaves it touch-disabled on some version. + setTouchEnabled(true); + setEnabled(true); + scheduleUpdate(); + + m_sprite = CCSprite::createWithTexture(eaiMakeBubbleTexture()); + m_sprite->setScale(0.55f); // 112 px art -> ~62 px on screen (compact, in the 55-65 px band) + CCSize win = CCDirector::get()->getWinSize(); + m_sprite->setPosition(clampPos(CCPoint( + (float)editoraiGetSavedInt("eai-bubble-cx", 40), + (float)editoraiGetSavedInt("eai-bubble-cy", + (int64_t)(win.height - 120)) + ))); + this->addChild(m_sprite); + + m_label = CCLabelBMFont::create("AI", "bigFont.fnt"); + m_label->setScale(0.8f); + CCSize ss = m_sprite->getContentSize(); + m_label->setPosition(CCPoint(ss.width / 2.f, ss.height / 2.f)); + m_sprite->addChild(m_label); + + if (auto* scene = CCScene::get()) + scene->addChild(this); + return true; + } + + CCPoint clampPos(CCPoint p) { + CCSize win = CCDirector::get()->getWinSize(); + constexpr float R = 33.f; // visual radius ~31 + 2 px margin + p.x = std::clamp(p.x, R, std::max(R, win.width - R)); + p.y = std::clamp(p.y, R, std::max(R, win.height - R)); + return p; + } + + // Any touch (re)starts the idle clock at full opacity. + void poke() { + m_idleT = 0.f; + m_opacityTarget = 1.f; + } + + void applyOpacity() { + auto o = (GLubyte)(m_opacity * 255); + m_sprite->setOpacity(o); + m_label->setOpacity(o); + } + + void update(float dt) override { + // Hidden completely inside any level — invisible, eats no taps. + // refreshForScene() is the single source of truth (also called from + // get() every ImGui frame); update() just drives the idle-dim when + // visible. + refreshForScene(); + if (!isVisible()) return; + m_idleT += dt; + if (m_idleT >= IDLE_DELAY) m_opacityTarget = DIM_OPACITY; + if (m_opacity != m_opacityTarget) { + float step = dt * 3.f; + m_opacity += std::clamp(m_opacityTarget - m_opacity, -step, step); + applyOpacity(); + } + } + + bool ccTouchBegan(CCTouch* touch, CCEvent*) override { + if (!isVisible()) return false; + if (PlayLayer::get()) return false; // belt & suspenders: never eat taps in-level + CCPoint p = convertToNodeSpace(touch->getLocation()); + CCPoint sp = m_sprite->getPosition(); + if (ccpDistance(p, sp) > TOUCH_RADIUS) return false; + m_haveMoved = false; + m_pressPos = p; + m_homePos = sp; // restore point if the finger lifts as a tap (kills tremble jump) + m_grabOff = CCPoint(p.x - sp.x, p.y - sp.y); + poke(); + return true; // claim the touch — it started inside the bubble + } + + void ccTouchMoved(CCTouch* touch, CCEvent*) override { + // Follow the finger from the very first pixel so drag feels instant + // (single press-and-move, no double-tap). The slop only decides at + // release time whether this gesture was a tap or a drag. + CCPoint p = convertToNodeSpace(touch->getLocation()); + if (!m_haveMoved && ccpDistance(p, m_pressPos) >= DRAG_SLOP) + m_haveMoved = true; + poke(); + m_sprite->setPosition(clampPos(CCPoint(p.x - m_grabOff.x, p.y - m_grabOff.y))); + } + + void ccTouchEnded(CCTouch*, CCEvent*) override { + if (m_haveMoved) { + CCPoint sp = m_sprite->getPosition(); + editoraiSetSavedInt("eai-bubble-cx", (int64_t)sp.x); + editoraiSetSavedInt("eai-bubble-cy", (int64_t)sp.y); + } else { + // Pure tap: undo any sub-slop tremble shift, then toggle panel. + m_sprite->setPosition(m_homePos); + g_st.panelOpen = !g_st.panelOpen; + } + poke(); + } + + void ccTouchCancelled(CCTouch*, CCEvent*) override { + // A cancelled gesture (system interruption, notification shade, + // multi-touch preemption, ...) is NOT a tap and NOT a completed + // drag: never toggle the panel and never persist a mid-drag bubble + // position. Just roll back to the pre-press position and clear the + // active-touch state. + if (m_sprite) m_sprite->setPosition(m_homePos); + m_haveMoved = false; + poke(); + } + + void registerWithTouchDispatcher() override { + // Very high priority + swallow: touches starting on the bubble reach + // it first; anything outside passes through untouched. + CCTouchDispatcher::get()->addTargetedDelegate(this, -1000, true); + } +}; +#endif + void drawOverlay() { float dt = ImGui::GetIO().DeltaTime; if (!g_themeLoaded) loadTheme(); @@ -2035,19 +2345,15 @@ void drawOverlay() { editoraiPersistSessionsIfDirty(); } - // Floating bubble — the touch-device way in (no E key there). Hidden - // during gameplay: an always-on window would eat taps in its rect, and - // mid-attempt is never the moment. - if (uiMobile() && !PlayLayer::get()) { - ImGui::SetNextWindowPos(ImVec2(8, 60), ImGuiCond_FirstUseEver); - if (ImGui::Begin("##eaibubble", nullptr, - ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_AlwaysAutoResize | - ImGuiWindowFlags_NoCollapse)) { - if (ImGui::Button("AI", ImVec2(44, 44))) - g_st.panelOpen = !g_st.panelOpen; - } - ImGui::End(); - } + // Floating bubble — the touch-device way in (no E key there). This is a + // NATIVE cocos button (Eclipse-style targeted touch delegate, created + // below), not an ImGui window: ImGui's touch emulation only delivers + // hover on the first tap, which forced a double-press before any drag. + // Raw touches drag from the very first press. Hidden completely inside + // levels, draggable (position saved), auto-dims to 50% after 5 s idle. +#ifdef GEODE_IS_MOBILE + EAIBubble::get(); +#endif // "Go to level" — a finished generation is waiting for its target level. // Tiny, pinned bottom-right, never over GD's own corner buttons; inert @@ -2185,13 +2491,26 @@ void drawOverlay() { else ImGui::TextColored(COL_DIM, "idle"); - // Right-aligned dismiss hint, out of the reading path. - std::string hint = uiMobile() - ? std::string("bubble / X hides") + // Right-aligned dismiss hint, out of the reading path. On touch + // screens the title-bar X is too small to hit reliably and the + // full-screen panel has no other way out, so mobile also gets a + // big dedicated close button. + std::string hint = mobile + ? std::string("bubble hides") : fmt::format("{} hides", keySeqDisplayName(overlayToggleSeq())); - float hw = ImGui::CalcTextSize(hint.c_str()).x; + float btnW = mobile ? 56.f : 0.f; + float gap = mobile ? 8.f : 0.f; + float hw = ImGui::CalcTextSize(hint.c_str()).x + btnW + gap; ImGui::SameLine(std::max(ImGui::GetCursorPosX() + 20.f, ImGui::GetContentRegionMax().x - hw)); + if (mobile) { + if (ImGui::Button("X##eai-close", ImVec2(btnW, 34.f))) { + g_st.panelOpen = false; + g_keyCapture = false; + } + tipIfHovered("Hide the panel (the AI bubble brings it back)."); + ImGui::SameLine(0.f, gap); + } ImGui::TextColored(ImVec4(COL_DIM.x, COL_DIM.y, COL_DIM.z, 0.65f), "%s", hint.c_str()); }